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 }