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 }