From c0bde8e3268a2f1ba05166b174a0d733237dc246 Mon Sep 17 00:00:00 2001 From: Chris Jaekl Date: Thu, 29 Oct 2015 22:52:09 +0900 Subject: [PATCH] Add a bare-bones implementation of SMTP. Needs testing. --- prod/net/jaekl/qd/QDException.java | 4 + prod/net/jaekl/qd/util/MailException.java | 19 ++ prod/net/jaekl/qd/util/MimePart.java | 16 ++ prod/net/jaekl/qd/util/SendMail.java | 290 ++++++++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100644 prod/net/jaekl/qd/util/MailException.java create mode 100644 prod/net/jaekl/qd/util/MimePart.java create mode 100644 prod/net/jaekl/qd/util/SendMail.java diff --git a/prod/net/jaekl/qd/QDException.java b/prod/net/jaekl/qd/QDException.java index f5478c1..a12aa9c 100644 --- a/prod/net/jaekl/qd/QDException.java +++ b/prod/net/jaekl/qd/QDException.java @@ -8,6 +8,10 @@ public class QDException extends Exception super(); } + public QDException(String msg) { + super(msg); + } + public QDException(Throwable t) { super(t); } diff --git a/prod/net/jaekl/qd/util/MailException.java b/prod/net/jaekl/qd/util/MailException.java new file mode 100644 index 0000000..b59f2a8 --- /dev/null +++ b/prod/net/jaekl/qd/util/MailException.java @@ -0,0 +1,19 @@ +package net.jaekl.qd.util; + +import net.jaekl.qd.QDException; + +public class MailException extends QDException { + private static final long serialVersionUID = 1L; + + public MailException(String msg) { + super(msg); + } + + public MailException(String cmd, String expected, String actual) { + super("Server responded to \"" + cmd + "\" with \"" + actual + "\", but I was expecting \"" + expected + "\"."); + } + + public MailException(Throwable cause) { + super(cause); + } +} diff --git a/prod/net/jaekl/qd/util/MimePart.java b/prod/net/jaekl/qd/util/MimePart.java new file mode 100644 index 0000000..198a9cf --- /dev/null +++ b/prod/net/jaekl/qd/util/MimePart.java @@ -0,0 +1,16 @@ +package net.jaekl.qd.util; + +public class MimePart { + String m_mimeType; + String m_content; + + public MimePart(String mimeType, String content) { + m_mimeType = mimeType; + m_content = content; + } + + // Future plans... add base64 encoding of byte[] / InputStream via alternative constructor? + + public String getMimeType() { return m_mimeType; } + public String getContent() { return m_content; } +} diff --git a/prod/net/jaekl/qd/util/SendMail.java b/prod/net/jaekl/qd/util/SendMail.java new file mode 100644 index 0000000..ce21ea3 --- /dev/null +++ b/prod/net/jaekl/qd/util/SendMail.java @@ -0,0 +1,290 @@ +package net.jaekl.qd.util; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.InetAddress; +import java.net.Socket; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; + +public class SendMail { + private static final String RESP_220 = "220"; // server ready + private static final String RESP_221 = "221"; // goodbye + private static final String RESP_250 = "250"; // success + private static final String RESP_251 = "251"; // mail address change (we don't support this) + private static final String RESP_354 = "354"; // OK, proceed with DATA transmission + + String m_hostName; + String m_boundary; + + String m_smtpHost; + int m_smtpPort; + String m_from; + String m_subject; + ArrayList m_to; + ArrayList m_cc; + ArrayList m_bcc; + ArrayList m_part; + + public SendMail() { + m_hostName = null; + m_boundary = null; + + m_smtpHost = "localhost"; + m_smtpPort = 25; + m_from = "noreply@localhost"; + m_subject = ""; + m_to = new ArrayList(); + m_cc = new ArrayList(); + m_bcc = new ArrayList(); + m_part = new ArrayList(); + } + + public void setSmtpHost(String hostName) { m_smtpHost = hostName; } + public void setSmtpPort(int portNum) { m_smtpPort = portNum; } + public void setFrom(String addr) { m_from = addr; } + public void setSubject(String subject) { m_subject = subject; } + public void addTo(String addr) { m_to.add(addr); } + public void addCc(String addr) { m_cc.add(addr); } + public void addBcc(String addr) { m_bcc.add(addr); } + public void addPart(MimePart part) { m_part.add(part); } + + public void send() throws MailException + { + try ( + Socket sock = new Socket(m_smtpHost, m_smtpPort); + PrintWriter pw = new PrintWriter(sock.getOutputStream(), true); + BufferedReader br = new BufferedReader(new InputStreamReader(sock.getInputStream())); + ) + { + send(pw, br); + } + catch (IOException exc) { + throw new MailException(exc); + } + } + + String getHostName() throws UnknownHostException { + if (null == m_hostName) { + m_hostName = InetAddress.getLocalHost().getHostName(); + } + return m_hostName; + } + + void send(PrintWriter pw, BufferedReader br) throws MailException + { + try { + String cmd; + String line; + + line = readResponse(br); + validateResponse("", RESP_220, line); + + cmd = "HELO " + getHostName(); + validateResponse(cmd, RESP_250, line); + + cmd = "MAIL FROM: " + m_from; + line = sendLine(pw, br, cmd); + validateResponse(cmd, RESP_250, line); + + sendRcpt(pw, br, m_to); + sendRcpt(pw, br, m_cc); + sendRcpt(pw, br, m_bcc); + + cmd = "DATA"; + line = sendLine(pw, br, cmd); + validateResponse(cmd, RESP_354, line); + + line = sendData(pw, br); + validateResponse(cmd, RESP_250, line); + + cmd = "QUIT"; + line = sendLine(pw, br, cmd); + validateResponse(cmd, RESP_221, line); + } + catch (IOException exc) { + throw new MailException(exc); + } + } + + // Send the DATA content of the email + String sendData(PrintWriter pw, BufferedReader br) throws MailException, IOException + { + sendLine(pw, "From: " + m_from); + sendAddressee(pw, "To", m_to); + sendAddressee(pw, "Cc", m_cc); + sendAddressee(pw, "Bcc", m_bcc); + sendLine(pw, "Subject: " + m_subject); + sendMimeHeaders(pw); + + sendLine(pw, ""); + + sendMimeParts(pw); + + String result = sendLine(pw, br, "\r\n.\r\n"); + return result; + } + + void sendMimeHeaders(PrintWriter pw) + { + sendLine(pw, "MIME-Version: 1.0"); + sendLine(pw, "Content-Type: multipart/mixed; boundary=" + getBoundary()); + } + + void sendMimeParts(PrintWriter pw) { + for (MimePart part : m_part) { + sendLine(pw, "--" + getBoundary()); + sendLine(pw, "Content-Type: " + part.getMimeType()); // TODO: Add support for encodings + sendLine(pw, ""); + sendLine(pw, part.getContent()); + } + sendLine(pw, "--" + getBoundary() + "--"); + } + + boolean isBoundaryOk(String candidate) { + for (MimePart part : m_part) { + if (part.getContent().contains(candidate)) { + return false; + } + } + return true; + } + + String getBoundary() + { + if (null != m_boundary) { + return m_boundary; + } + + StringBuilder sb = new StringBuilder("snip_snip"); + if (isBoundaryOk(sb.toString())) { + return sb.toString(); + } + + sb.append('_'); + char ch = 'Z'; + + while (true) { + if (isBoundaryOk(sb.toString())) { + m_boundary = sb.toString(); + return m_boundary; + } + sb.append(ch); + + ch--; + if (ch < 'A') { + ch = 'Z'; + } + } + } + + void sendAddressee(PrintWriter pw, String designator, List addrs) throws MailException + { + for (String addr : addrs) { + sendLine(pw, designator + ": " + addr); + } + } + + void validateResponse(String cmd, String actual, String expected) throws MailException + { + if (! actual.startsWith(expected)) { + throw new MailException(cmd, expected, actual); + } + } + + void sendRcpt(PrintWriter pw, BufferedReader br, List addrs) throws IOException, MailException + { + for (String to : addrs) { + String cmd = "RCPT TO: " + to; + String line = sendLine(pw, br, cmd); + if (line.startsWith(RESP_251)) { + throw new MailException("Apology: no support for 251 mail forwarding response to RCPT command. Mail will not be delivered: " + line); + } + if (!line.startsWith(RESP_250)) { + throw new MailException(cmd, RESP_250, line); + } + } + + } + + // RFC 5321 specifies that all response codes are three digits + // at the start of a new line. + // These digits may optionally be followed by a space and/or text. + // If the response is spread over more than one line, then all but + // the last line will have a '-' immediately after the 3rd digit. + // + // This function returns true iff. this line is a response code + // that is not continued on the next line. + boolean isFinalResponseCode(String line) + { + if (null == line) { + return false; + } + + String trimmed = line.trim(); + + if (trimmed.length() < 3) { + return false; + } + if (! ( (Character.isDigit(trimmed.charAt(0))) + && (Character.isDigit(trimmed.charAt(1))) + && (Character.isDigit(trimmed.charAt(2))) ) ) + { + return false; + } + + if (3 == trimmed.length()) { + return true; + } + + if (' ' == trimmed.charAt(3)) { + return true; + } + + if ('-' == trimmed.charAt(3)) { + return false; // This is a response code, but it's continued on the next line + } + + // This doesn't look like a response code + return false; + } + + int responseCode(String line) + { + // RFC 5321 specifies that all response codes are three digits + // at the start of a new line. + // These digits may optionally be followed by a space and/or text. + + if ((null == line) || (line.length() < 3)) { + return 0; + } + + return Integer.parseInt(line.substring(0, 3)); + } + + String readResponse(BufferedReader br) throws IOException + { + String line; + + while (null != (line = br.readLine())) { + if (isFinalResponseCode(line)) { + return line; + } + } + return ""; + } + + void sendLine(PrintWriter pw, String line) + { + pw.write(line + "\r\n"); + } + + String sendLine(PrintWriter pw, BufferedReader br, String line) throws IOException + { + sendLine(pw, line); + return readResponse(br); + } +} -- 2.39.2