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 /// Authenticate using XOAUTH2 protocol 76 /// 77 /// Works for GMail and Office 365 78 /// 79 /// See_Also: 80 /// - https://developers.google.com/gmail/imap/xoauth2-protocol#using_oauth_20 81 /// - https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth 82 xoauth2, 83 } 84 85 86 /** 87 Configuration options for the SMTP client. 88 */ 89 final class SMTPClientSettings { 90 /// SMTP host to connect to 91 string host = "127.0.0.1"; 92 /// Port on which to connect 93 ushort port = 25; 94 /// Own network name to report to the SMTP server 95 string localname = "localhost"; 96 /// Type of encryption protocol to use 97 SMTPConnectionType connectionType = SMTPConnectionType.plain; 98 /// Authentication type to use 99 SMTPAuthType authType = SMTPAuthType.none; 100 101 /// Determines how the server certificate gets validated. 102 TLSPeerValidationMode tlsValidationMode = TLSPeerValidationMode.trustedCert; 103 /// Version(s) of the TLS/SSL protocol to use 104 TLSVersion tlsVersion = TLSVersion.any; 105 /// Callback to invoke to enable additional setup of the TLS context. 106 void delegate(scope TLSContext) tlsContextSetup; 107 108 /// User name to use for authentication 109 string username; 110 /// Password to use for authentication 111 string password; 112 113 this() {} 114 this(string host, ushort port) { this.host = host; this.port = port; } 115 } 116 117 118 /** 119 Represents an email message, including its headers. 120 */ 121 final class Mail { 122 InetHeaderMap headers; 123 string bodyText; 124 } 125 126 /** 127 Sends an e-mail using the given settings. 128 129 The mail parameter must point to a valid $(D Mail) object and should define 130 at least the headers "To", "From", Sender" and "Subject". 131 132 Valid headers can be found at http://tools.ietf.org/html/rfc4021 133 */ 134 void sendMail(in SMTPClientSettings settings, Mail mail) 135 { 136 TCPConnection raw_conn; 137 try { 138 raw_conn = connectTCP(settings.host, settings.port); 139 } catch(Exception e){ 140 throw new Exception("Failed to connect to SMTP server at "~settings.host~" port " 141 ~to!string(settings.port), e); 142 } 143 scope(exit) raw_conn.close(); 144 145 InterfaceProxy!Stream conn = raw_conn; 146 147 if( settings.connectionType == SMTPConnectionType.tls ){ 148 auto ctx = createTLSContext(TLSContextKind.client, settings.tlsVersion); 149 ctx.peerValidationMode = settings.tlsValidationMode; 150 if (settings.tlsContextSetup) settings.tlsContextSetup(ctx); 151 conn = createTLSStream(raw_conn, ctx, TLSStreamState.connecting, settings.host, raw_conn.remoteAddress); 152 } 153 154 expectStatus(conn, SMTPStatus.serviceReady, "connection establishment"); 155 156 void greet() @safe { 157 conn.write("EHLO "~settings.localname~"\r\n"); 158 while(true){ // simple skipping of 159 auto ln = () @trusted { return cast(string)conn.readLine(); } (); 160 logDebug("EHLO response: %s", ln); 161 auto sidx = ln.indexOf(' '); 162 auto didx = ln.indexOf('-'); 163 if( sidx > 0 && (didx < 0 || didx > sidx) ){ 164 enforce(ln[0 .. sidx] == "250", "Server not ready (response "~ln[0 .. sidx]~")"); 165 break; 166 } 167 } 168 } 169 170 greet(); 171 172 if (settings.connectionType == SMTPConnectionType.startTLS) { 173 conn.write("STARTTLS\r\n"); 174 expectStatus(conn, SMTPStatus.serviceReady, "STARTTLS"); 175 auto ctx = createTLSContext(TLSContextKind.client, settings.tlsVersion); 176 ctx.peerValidationMode = settings.tlsValidationMode; 177 if (settings.tlsContextSetup) settings.tlsContextSetup(ctx); 178 conn = createTLSStream(raw_conn, ctx, TLSStreamState.connecting, settings.host, raw_conn.remoteAddress); 179 greet(); 180 } 181 182 final switch (settings.authType) { 183 case SMTPAuthType.none: break; 184 case SMTPAuthType.plain: 185 logDebug("seding auth"); 186 conn.write("AUTH PLAIN\r\n"); 187 expectStatus(conn, SMTPStatus.serverAuthReady, "AUTH PLAIN"); 188 logDebug("seding auth info"); 189 conn.write(Base64.encode(cast(const(ubyte)[])("\0"~settings.username~"\0"~settings.password))); 190 conn.write("\r\n"); 191 expectStatus(conn, 235, "plain auth info"); 192 logDebug("authed"); 193 break; 194 case SMTPAuthType.login: 195 conn.write("AUTH LOGIN\r\n"); 196 expectStatus(conn, SMTPStatus.serverAuthReady, "AUTH LOGIN"); 197 conn.write(Base64.encode(cast(const(ubyte)[])settings.username) ~ "\r\n"); 198 expectStatus(conn, SMTPStatus.serverAuthReady, "login user name"); 199 conn.write(Base64.encode(cast(const(ubyte)[])settings.password) ~ "\r\n"); 200 expectStatus(conn, 235, "login password"); 201 break; 202 case SMTPAuthType.xoauth2: 203 // Ideally we should inspect the server's capabilities instead of 204 // making this decision, but since the RFC for OAuth requires TLS, 205 // be a bit conservative. 206 enforce(settings.connectionType != SMTPConnectionType.plain, 207 "Cannot use XOAUTH2 without TLS"); 208 conn.write("AUTH XOAUTH2\r\n"); 209 expectStatus(conn, SMTPStatus.serverAuthReady, "AUTH XOAUTH2"); 210 const authStr = "user=%s\1auth=Bearer %s\1\1".format( 211 settings.username, settings.password); 212 conn.write(Base64.encode(authStr.representation) ~ "\r\n"); 213 expectStatus(conn, 235, "XOAUTH2 authentication"); 214 break; 215 case SMTPAuthType.cramMd5: assert(false, "TODO!"); 216 } 217 218 conn.write("MAIL FROM:"~addressMailPart(mail.headers["From"])~"\r\n"); 219 expectStatus(conn, SMTPStatus.success, "MAIL FROM"); 220 221 static immutable rcpt_headers = ["To", "Cc", "Bcc"]; 222 foreach (h; rcpt_headers) { 223 mail.headers.getAll(h, (v) @safe { 224 foreach (a; v.splitter(',').map!(a => a.strip)) { 225 conn.write("RCPT TO:"~addressMailPart(a)~"\r\n"); 226 expectStatus(conn, SMTPStatus.success, "RCPT TO"); 227 } 228 }); 229 } 230 231 mail.headers.removeAll("Bcc"); 232 233 conn.write("DATA\r\n"); 234 expectStatus(conn, SMTPStatus.startMailInput, "DATA"); 235 foreach (name, value; mail.headers.byKeyValue) { 236 conn.write(name~": "~value~"\r\n"); 237 } 238 conn.write("\r\n"); 239 conn.write(mail.bodyText); 240 conn.write("\r\n.\r\n"); 241 expectStatus(conn, SMTPStatus.success, "message body"); 242 243 conn.write("QUIT\r\n"); 244 expectStatus(conn, SMTPStatus.serviceClosing, "QUIT"); 245 } 246 247 /** 248 The following example demonstrates the complete construction of a valid 249 e-mail object with UTF-8 encoding. The Date header, as demonstrated, must 250 be converted with the local timezone using the $(D toRFC822DateTimeString) 251 function. 252 */ 253 unittest { 254 import vibe.inet.message; 255 import std.datetime; 256 void testSmtp(string host, ushort port){ 257 Mail email = new Mail; 258 email.headers["Date"] = Clock.currTime(PosixTimeZone.getTimeZone("America/New_York")).toRFC822DateTimeString(); // uses UFCS 259 email.headers["Sender"] = "Domain.com Contact Form <no-reply@domain.com>"; 260 email.headers["From"] = "John Doe <joe@doe.com>"; 261 email.headers["To"] = "Customer Support <support@domain.com>"; 262 email.headers["Subject"] = "My subject"; 263 email.headers["Content-Type"] = "text/plain;charset=utf-8"; 264 email.bodyText = "This message can contain utf-8 [κόσμε], and\nwill be displayed properly in mail clients with \\n line endings."; 265 266 auto smtpSettings = new SMTPClientSettings(host, port); 267 sendMail(smtpSettings, email); 268 } 269 // testSmtp("localhost", 25); 270 } 271 272 private void expectStatus(InputStream)(InputStream conn, int expected_status, string in_response_to) 273 if (isInputStream!InputStream) 274 { 275 // TODO: make the full status message available in the exception 276 // message or for general use (e.g. determine server features) 277 string ln; 278 sizediff_t sp, dsh; 279 do { 280 ln = () @trusted { return cast(string)conn.readLine(); } (); 281 sp = ln.indexOf(' '); 282 if (sp < 0) sp = ln.length; 283 dsh = ln.indexOf('-'); 284 } while (dsh >= 0 && dsh < sp); 285 286 auto status = to!int(ln[0 .. sp]); 287 enforce(status == expected_status, "Expected status "~to!string(expected_status)~" in response to "~in_response_to~", got "~to!string(status)~": "~ln[sp .. $]); 288 } 289 290 private int recvStatus(InputStream conn) 291 { 292 string ln = () @trusted { return cast(string)conn.readLine(); } (); 293 auto sp = ln.indexOf(' '); 294 if( sp < 0 ) sp = ln.length; 295 return to!int(ln[0 .. sp]); 296 } 297 298 private string addressMailPart(string str) 299 { 300 auto idx = str.indexOf('<'); 301 if( idx < 0 ) return "<"~ str ~">"; 302 str = str[idx .. $]; 303 enforce(str[$-1] == '>', "Malformed email address field: '"~str~"'."); 304 return str; 305 }