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