1 module vibeauth.mail.base;
2 
3 import std.string;
4 import std.random;
5 import std.array;
6 import std.algorithm;
7 import std.conv;
8 
9 import vibeauth.users;
10 import vibeauth.token;
11 
12 import vibe.mail.smtp;
13 import vibe.stream.tls;
14 import vibe.data.json;
15 
16 struct MailTemplate {
17 	string subject;
18 
19 	string text;
20 	string html;
21 }
22 
23 struct SMTPConfig {
24 	@optional {
25 		string authType = "none";
26 		string connectionType = "plain";
27 		string tlsValidationMode = "none";
28 		string tlsVersion = "any";
29 
30 		string host;
31 		ushort port;
32 
33 		string localname;
34 		string password;
35 		string username;
36 	}
37 }
38 
39 struct EmailConfiguration {
40 	string from = "noreply@service.com";
41 
42 	@optional SMTPConfig smtp;
43 
44 	MailTemplate activation =
45 		MailTemplate("Confirmation instructions",
46 								"[location][activation]?email=[email]&token=[token]",
47 								"<a href=\"[location][activation]?email=[email]&token=[token]\">click here</a>");
48 
49 	MailTemplate resetPassword =
50 		MailTemplate("Reset password instructions",
51 								"[location][reset]?email=[email]&token=[token]",
52 								"<a href=\"[location][reset]?email=[email]&token=[token]\">click here</a>");
53 
54 	MailTemplate resetPasswordConfirmation =
55 		MailTemplate("Password changed",
56 								"Hello, [user.name]!\n\nThe password has successfully been changed.\n\nIf you did not initiate this change, please contact your administrator immediately.",
57 								"<h1>Hello, [user.name]!</h1><p>The password has successfully been changed.</p><p>If you did not initiate this change, please contact your administrator immediately.</p>");
58 
59 
60 }
61 
62 interface IMailSender {
63 	bool send(Message);
64 }
65 
66 interface IMailQueue {
67 	void addMessage(Message);
68 	void addActivationMessage(string email, Token token, string[string] variables);
69 	void addResetPasswordMessage(string email, Token token, string[string] variables);
70 	void addResetPasswordConfirmationMessage(string email, string[string] variables);
71 }
72 
73 struct Message {
74 	string from;
75 	string[] to;
76 	string subject;
77 
78 	string textMessage;
79 	string htmlMessage;
80 
81 	private {
82 		immutable boundaryCharList = "abcdefghijklmnopqrstuvwxyz0123456789";
83 		string _boundary;
84 	}
85 
86 	string boundary() {
87 		if(_boundary == "") {
88 			immutable len = boundaryCharList.length;
89 			_boundary = "".leftJustify(uniform(20, 30), ' ').map!(a => boundaryCharList[uniform(0, len)]).array;
90 		}
91 
92 		return _boundary;
93 	}
94 
95 	string[] headers() {
96 		string[] list;
97 
98 		if(htmlMessage.length > 0) {
99 			list ~= "MIME-Version: 1.0";
100 			list ~= `Content-Type: multipart/alternative; boundary="` ~ boundary ~ `"`;
101 		}
102 
103 		return list;
104 	}
105 
106 	string mailBody() {
107 		if(htmlMessage == "") {
108 			return textMessage;
109 		}
110 
111 		string message = "This is a multi-part message in MIME format\r\n\r\n";
112 		message ~= "--" ~ boundary ~ "\r\n";
113 		message ~= `Content-Type: text/plain; charset="utf-8"` ~ "\r\n\r\n";
114 		message ~= textMessage ~ "\r\n";
115 		message ~= "--" ~ boundary ~ "\r\n";
116 		message ~= `Content-Type: text/html; charset="utf-8"` ~ "\r\n\r\n";
117 		message ~= htmlMessage ~ "\r\n";
118 		message ~= "--" ~ boundary ~ "--";
119 
120 		return message;
121 	}
122 }
123 
124 /// it should add the multipart header if text and html message is present
125 unittest {
126 	auto message = Message();
127 	message.textMessage = "text";
128 	message.htmlMessage = "html";
129 
130 	message.headers[0].should.equal(`MIME-Version: 1.0`);
131 	message.headers[1].should.equal(`Content-Type: multipart/alternative; boundary="` ~ message.boundary ~ `"`);
132 }
133 
134 /// it should not add the multipart header if the html message is missing
135 unittest {
136 	auto message = Message();
137 	message.textMessage = "text";
138 
139 	message.headers.length.should.be.equal(0);
140 }
141 
142 /// it should generate an unique boundary
143 unittest {
144 	auto message1 = Message();
145 	auto message2 = Message();
146 
147 	message1.boundary.should.not.equal("");
148 	message1.boundary.should.not.be.equal(message2.boundary);
149 	message1.boundary.should.not.startWith(message2.boundary);
150 	message2.boundary.should.not.startWith(message1.boundary);
151 }
152 
153 /// body should contain only the text message when html is missing
154 unittest {
155 	auto message = Message();
156 	message.textMessage = "text";
157 
158 	message.mailBody.should.equal("text");
159 }
160 
161 /// body should contain a mime body
162 unittest {
163 	auto message = Message();
164 	message.textMessage = "text";
165 	message.htmlMessage = "html";
166 
167 	string expected = "This is a multi-part message in MIME format\r\n\r\n";
168 	expected ~= "--" ~ message.boundary ~ "\r\n";
169 	expected ~= `Content-Type: text/plain; charset="utf-8"` ~ "\r\n\r\n";
170 	expected ~= "text\r\n";
171 	expected ~= "--" ~ message.boundary ~ "\r\n";
172 	expected ~= `Content-Type: text/html; charset="utf-8"` ~ "\r\n\r\n";
173 	expected ~= "html\r\n";
174 	expected ~= "--" ~ message.boundary ~ "--";
175 
176 	message.mailBody.should.equal(expected);
177 }
178 
179 class MailQueue : IMailQueue {
180 
181 	protected {
182 		Message[] messages;
183 		const EmailConfiguration settings;
184 	}
185 
186 	this(const EmailConfiguration settings) {
187 		this.settings = settings;
188 	}
189 
190 	void addMessage(Message message) {
191 		messages ~= message;
192 	}
193 
194 	private void addMessage(MailTemplate mailTemplate, string email, string[string] variables) {
195 		Message message;
196 
197 		message.to ~= email;
198 		message.from = settings.from;
199 		message.subject = mailTemplate.subject;
200 
201 		message.textMessage = replaceVariables(mailTemplate.text, variables);
202 		message.htmlMessage = replaceVariables(mailTemplate.html, variables);
203 
204 		addMessage(message);
205 	}
206 
207 	void addResetPasswordMessage(string email, Token token, string[string] variables) {
208 		variables["email"] = email;
209 		variables["token"] = token.name;
210 
211 		addMessage(settings.resetPassword, email, variables);
212 	}
213 
214 	void addActivationMessage(string email, Token token, string[string] variables) {
215 		variables["email"] = email;
216 		variables["token"] = token.name;
217 
218 		addMessage(settings.activation, email, variables);
219 	}
220 
221 	void addResetPasswordConfirmationMessage(string email, string[string] variables) {
222 		variables["email"] = email;
223 
224 		addMessage(settings.resetPasswordConfirmation, email, variables);
225 	}
226 
227 	string replaceVariables(const(string) text, string[string] variables) {
228 		string data = text.dup;
229 
230 		foreach(string key, value; variables) {
231 			data = data.replace("[" ~ key ~ "]", value);
232 		}
233 
234 		return data;
235 	}
236 }
237 
238 version(unittest) {
239 	import fluent.asserts;
240 
241 	class MailQueueMock : MailQueue {
242 
243 		this(EmailConfiguration config) {
244 			super(config);
245 		}
246 
247 		auto lastMessage() {
248 			return messages[0];
249 		}
250 	}
251 }
252 
253 @("it should set the text and html activation message")
254 unittest {
255 	auto config = EmailConfiguration();
256 	config.from = "someone@service.com";
257 	config.activation.subject = "subject";
258 	config.activation.text = "text";
259 	config.activation.html = "html";
260 
261 	auto mailQueue = new MailQueueMock(config);
262 
263 	string[string] variables;
264 	mailQueue.addActivationMessage("user@gmail.com", Token(), variables);
265 
266 	mailQueue.lastMessage.to[0].should.be.equal("user@gmail.com");
267 	mailQueue.lastMessage.from.should.be.equal("someone@service.com");
268 	mailQueue.lastMessage.subject.should.be.equal("subject");
269 	mailQueue.lastMessage.textMessage.should.be.equal("text");
270 	mailQueue.lastMessage.htmlMessage.should.be.equal("html");
271 }
272 
273 @("it should set the text and html reset password message")
274 unittest {
275 	auto config = EmailConfiguration();
276 	config.from = "someone@service.com";
277 	config.resetPassword.subject = "subject";
278 	config.resetPassword.text = "text";
279 	config.resetPassword.html = "html";
280 
281 	auto mailQueue = new MailQueueMock(config);
282 
283 	string[string] variables;
284 	mailQueue.addResetPasswordMessage("user@gmail.com", Token(), variables);
285 
286 	mailQueue.lastMessage.to[0].should.be.equal("user@gmail.com");
287 	mailQueue.lastMessage.from.should.be.equal("someone@service.com");
288 	mailQueue.lastMessage.subject.should.be.equal("subject");
289 	mailQueue.lastMessage.textMessage.should.be.equal("text");
290 	mailQueue.lastMessage.htmlMessage.should.be.equal("html");
291 }