Add a bare-bones implementation of SMTP.
authorChris Jaekl <cejaekl@yahoo.com>
Thu, 29 Oct 2015 13:52:09 +0000 (22:52 +0900)
committerChris Jaekl <cejaekl@yahoo.com>
Thu, 29 Oct 2015 13:52:09 +0000 (22:52 +0900)
Needs testing.

prod/net/jaekl/qd/QDException.java
prod/net/jaekl/qd/util/MailException.java [new file with mode: 0644]
prod/net/jaekl/qd/util/MimePart.java [new file with mode: 0644]
prod/net/jaekl/qd/util/SendMail.java [new file with mode: 0644]

index f5478c1157fd3dc4c94d8cab8ccfb4c3688509f7..a12aa9c044e6c3041c9a480d184f42330db75bd5 100644 (file)
@@ -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 (file)
index 0000000..b59f2a8
--- /dev/null
@@ -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 (file)
index 0000000..198a9cf
--- /dev/null
@@ -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 (file)
index 0000000..ce21ea3
--- /dev/null
@@ -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<String> m_to;
+       ArrayList<String> m_cc;
+       ArrayList<String> m_bcc;
+       ArrayList<MimePart> 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<String>();
+               m_cc = new ArrayList<String>();
+               m_bcc = new ArrayList<String>();
+               m_part = new ArrayList<MimePart>();
+       }
+       
+       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<String> 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<String> 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);
+       }       
+}