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 }