1 /** 2 SMTP client implementation 3 4 Copyright: © 2012-2015 Sönke Ludwig 5 License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file. 6 Authors: Sönke Ludwig 7 */ 8 module vibe.mail.smtp; 9 10 import vibe.core.log; 11 import vibe.core.net; 12 import vibe.inet.message; 13 import vibe.stream.operations; 14 import vibe.stream.tls; 15 import vibe.internal.interfaceproxy; 16 17 import std.algorithm : map, splitter; 18 import std.base64; 19 import std.conv; 20 import std.exception; 21 import std.string; 22 23 @safe: 24 25 26 /** 27 Determines the (encryption) type of an SMTP connection. 28 */ 29 enum SMTPConnectionType { 30 plain, 31 tls, 32 startTLS, 33 } 34 35 36 /** Represents the different status codes for SMTP replies. 37 */ 38 enum SMTPStatus { 39 _success = 200, 40 systemStatus = 211, 41 helpMessage = 214, 42 serviceReady = 220, 43 serviceClosing = 221, 44 success = 250, 45 forwarding = 251, 46 serverAuthReady = 334, 47 startMailInput = 354, 48 serviceUnavailable = 421, 49 mailboxTemporarilyUnavailable = 450, 50 processingError = 451, 51 outOfDiskSpace = 452, 52 commandUnrecognized = 500, 53 invalidParameters = 501, 54 commandNotImplemented = 502, 55 badCommandSequence = 503, 56 commandParameterNotImplemented = 504, 57 domainAcceptsNoMail = 521, 58 accessDenied = 530, 59 mailboxUnavailable = 550, 60 userNotLocal = 551, 61 exceededStorageAllocation = 552, 62 mailboxNameNotAllowed = 553, 63 transactionFailed = 554 64 } 65 66 67 /** 68 Represents the authentication mechanism used by the SMTP client. 69 */ 70 enum SMTPAuthType { 71 none, 72 plain, 73 login, 74 cramMd5 75 } 76 77 78 /** 79 Configuration options for the SMTP client. 80 */ 81 final class SMTPClientSettings { 82 /// SMTP host to connect to 83 string host = "127.0.0.1"; 84 /// Port on which to connect 85 ushort port = 25; 86 /// Own network name to report to the SMTP server 87 string localname = "localhost"; 88 /// Type of encryption protocol to use 89 SMTPConnectionType connectionType = SMTPConnectionType.plain; 90 /// Authentication type to use 91 SMTPAuthType authType = SMTPAuthType.none; 92 93 /// Determines how the server certificate gets validated. 94 TLSPeerValidationMode tlsValidationMode = TLSPeerValidationMode.trustedCert; 95 /// Version(s) of the TLS/SSL protocol to use 96 TLSVersion tlsVersion = TLSVersion.any; 97 /// Callback to invoke to enable additional setup of the TLS context. 98 void delegate(scope TLSContext) tlsContextSetup; 99 100 /// User name to use for authentication 101 string username; 102 /// Password to use for authentication 103 string password; 104 105 this() {} 106 this(string host, ushort port) { this.host = host; this.port = port; } 107 } 108 109 110 /** 111 Represents an email message, including its headers. 112 */ 113 final class Mail { 114 InetHeaderMap headers; 115 string bodyText; 116 } 117 118 /** 119 Sends an e-mail using the given settings. 120 121 The mail parameter must point to a valid $(D Mail) object and should define 122 at least the headers "To", "From", Sender" and "Subject". 123 124 Valid headers can be found at http://tools.ietf.org/html/rfc4021 125 */ 126 void sendMail(in SMTPClientSettings settings, Mail mail) 127 { 128 TCPConnection raw_conn; 129 try { 130 raw_conn = connectTCP(settings.host, settings.port); 131 } catch(Exception e){ 132 throw new Exception("Failed to connect to SMTP server at "~settings.host~" port " 133 ~to!string(settings.port), e); 134 } 135 scope(exit) raw_conn.close(); 136 137 InterfaceProxy!Stream conn = raw_conn; 138 139 if( settings.connectionType == SMTPConnectionType.tls ){ 140 auto ctx = createTLSContext(TLSContextKind.client, settings.tlsVersion); 141 ctx.peerValidationMode = settings.tlsValidationMode; 142 if (settings.tlsContextSetup) settings.tlsContextSetup(ctx); 143 conn = createTLSStream(raw_conn, ctx, TLSStreamState.connecting, settings.host, raw_conn.remoteAddress); 144 } 145 146 expectStatus(conn, SMTPStatus.serviceReady, "connection establishment"); 147 148 void greet() @safe { 149 conn.write("EHLO "~settings.localname~"\r\n"); 150 while(true){ // simple skipping of 151 auto ln = () @trusted { return cast(string)conn.readLine(); } (); 152 logDebug("EHLO response: %s", ln); 153 auto sidx = ln.indexOf(' '); 154 auto didx = ln.indexOf('-'); 155 if( sidx > 0 && (didx < 0 || didx > sidx) ){ 156 enforce(ln[0 .. sidx] == "250", "Server not ready (response "~ln[0 .. sidx]~")"); 157 break; 158 } 159 } 160 } 161 162 greet(); 163 164 if (settings.connectionType == SMTPConnectionType.startTLS) { 165 conn.write("STARTTLS\r\n"); 166 expectStatus(conn, SMTPStatus.serviceReady, "STARTTLS"); 167 auto ctx = createTLSContext(TLSContextKind.client, settings.tlsVersion); 168 ctx.peerValidationMode = settings.tlsValidationMode; 169 if (settings.tlsContextSetup) settings.tlsContextSetup(ctx); 170 conn = createTLSStream(raw_conn, ctx, TLSStreamState.connecting, settings.host, raw_conn.remoteAddress); 171 greet(); 172 } 173 174 final switch (settings.authType) { 175 case SMTPAuthType.none: break; 176 case SMTPAuthType.plain: 177 logDebug("seding auth"); 178 conn.write("AUTH PLAIN\r\n"); 179 expectStatus(conn, SMTPStatus.serverAuthReady, "AUTH PLAIN"); 180 logDebug("seding auth info"); 181 conn.write(Base64.encode(cast(const(ubyte)[])("\0"~settings.username~"\0"~settings.password))); 182 conn.write("\r\n"); 183 expectStatus(conn, 235, "plain auth info"); 184 logDebug("authed"); 185 break; 186 case SMTPAuthType.login: 187 conn.write("AUTH LOGIN\r\n"); 188 expectStatus(conn, SMTPStatus.serverAuthReady, "AUTH LOGIN"); 189 conn.write(Base64.encode(cast(const(ubyte)[])settings.username) ~ "\r\n"); 190 expectStatus(conn, SMTPStatus.serverAuthReady, "login user name"); 191 conn.write(Base64.encode(cast(const(ubyte)[])settings.password) ~ "\r\n"); 192 expectStatus(conn, 235, "login password"); 193 break; 194 case SMTPAuthType.cramMd5: assert(false, "TODO!"); 195 } 196 197 conn.write("MAIL FROM:"~addressMailPart(mail.headers["From"])~"\r\n"); 198 expectStatus(conn, SMTPStatus.success, "MAIL FROM"); 199 200 static immutable rcpt_headers = ["To", "Cc", "Bcc"]; 201 foreach (h; rcpt_headers) { 202 mail.headers.getAll(h, (v) @safe { 203 foreach (a; v.splitter(',').map!(a => a.strip)) { 204 conn.write("RCPT TO:"~addressMailPart(a)~"\r\n"); 205 expectStatus(conn, SMTPStatus.success, "RCPT TO"); 206 } 207 }); 208 } 209 210 mail.headers.removeAll("Bcc"); 211 212 conn.write("DATA\r\n"); 213 expectStatus(conn, SMTPStatus.startMailInput, "DATA"); 214 foreach (name, value; mail.headers.byKeyValue) { 215 conn.write(name~": "~value~"\r\n"); 216 } 217 conn.write("\r\n"); 218 conn.write(mail.bodyText); 219 conn.write("\r\n.\r\n"); 220 expectStatus(conn, SMTPStatus.success, "message body"); 221 222 conn.write("QUIT\r\n"); 223 expectStatus(conn, SMTPStatus.serviceClosing, "QUIT"); 224 } 225 226 /** 227 The following example demonstrates the complete construction of a valid 228 e-mail object with UTF-8 encoding. The Date header, as demonstrated, must 229 be converted with the local timezone using the $(D toRFC822DateTimeString) 230 function. 231 */ 232 unittest { 233 import vibe.inet.message; 234 import std.datetime; 235 void testSmtp(string host, ushort port){ 236 Mail email = new Mail; 237 email.headers["Date"] = Clock.currTime(PosixTimeZone.getTimeZone("America/New_York")).toRFC822DateTimeString(); // uses UFCS 238 email.headers["Sender"] = "Domain.com Contact Form <no-reply@domain.com>"; 239 email.headers["From"] = "John Doe <joe@doe.com>"; 240 email.headers["To"] = "Customer Support <support@domain.com>"; 241 email.headers["Subject"] = "My subject"; 242 email.headers["Content-Type"] = "text/plain;charset=utf-8"; 243 email.bodyText = "This message can contain utf-8 [κόσμε], and\nwill be displayed properly in mail clients with \\n line endings."; 244 245 auto smtpSettings = new SMTPClientSettings(host, port); 246 sendMail(smtpSettings, email); 247 } 248 // testSmtp("localhost", 25); 249 } 250 251 private void expectStatus(InputStream)(InputStream conn, int expected_status, string in_response_to) 252 if (isInputStream!InputStream) 253 { 254 // TODO: make the full status message available in the exception 255 // message or for general use (e.g. determine server features) 256 string ln; 257 sizediff_t sp, dsh; 258 do { 259 ln = () @trusted { return cast(string)conn.readLine(); } (); 260 sp = ln.indexOf(' '); 261 if (sp < 0) sp = ln.length; 262 dsh = ln.indexOf('-'); 263 } while (dsh >= 0 && dsh < sp); 264 265 auto status = to!int(ln[0 .. sp]); 266 enforce(status == expected_status, "Expected status "~to!string(expected_status)~" in response to "~in_response_to~", got "~to!string(status)~": "~ln[sp .. $]); 267 } 268 269 private int recvStatus(InputStream conn) 270 { 271 string ln = () @trusted { return cast(string)conn.readLine(); } (); 272 auto sp = ln.indexOf(' '); 273 if( sp < 0 ) sp = ln.length; 274 return to!int(ln[0 .. sp]); 275 } 276 277 private string addressMailPart(string str) 278 { 279 auto idx = str.indexOf('<'); 280 if( idx < 0 ) return "<"~ str ~">"; 281 str = str[idx .. $]; 282 enforce(str[$-1] == '>', "Malformed email address field: '"~str~"'."); 283 return str; 284 }