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 }