| 1 | module smtp |
| 2 | |
| 3 | /* |
| 4 | * |
| 5 | * smtp module |
| 6 | * Created by: nedimf (07/2020) |
| 7 | */ |
| 8 | import net |
| 9 | import net.ssl |
| 10 | import encoding.base64 |
| 11 | import strings |
| 12 | import time |
| 13 | import io |
| 14 | import rand |
| 15 | |
| 16 | const recv_size = 128 |
| 17 | |
| 18 | enum ReplyCode { |
| 19 | ready = 220 |
| 20 | close = 221 |
| 21 | auth_ok = 235 |
| 22 | action_ok = 250 |
| 23 | mail_start = 354 |
| 24 | } |
| 25 | |
| 26 | pub enum BodyType { |
| 27 | text |
| 28 | html |
| 29 | } |
| 30 | |
| 31 | // Message stores one body variant and optional attachments for a Mail. |
| 32 | pub struct Message { |
| 33 | pub: |
| 34 | body string |
| 35 | attachments []Attachment |
| 36 | } |
| 37 | |
| 38 | // Config stores the settings used to connect a new SMTP client. |
| 39 | pub struct Config { |
| 40 | pub: |
| 41 | server string |
| 42 | port int = 25 |
| 43 | username string |
| 44 | password string |
| 45 | from string |
| 46 | ssl bool |
| 47 | starttls bool |
| 48 | timeout time.Duration |
| 49 | } |
| 50 | |
| 51 | pub struct Client { |
| 52 | Config |
| 53 | mut: |
| 54 | conn net.TcpConn |
| 55 | ssl_conn &ssl.SSLConn = unsafe { nil } |
| 56 | reader ?&io.BufferedReader |
| 57 | pub mut: |
| 58 | is_open bool |
| 59 | encrypted bool |
| 60 | } |
| 61 | |
| 62 | // Mail stores the message headers and MIME payload sent by Client.send. |
| 63 | pub struct Mail { |
| 64 | pub: |
| 65 | from string |
| 66 | to string |
| 67 | cc string |
| 68 | bcc string |
| 69 | date time.Time = time.now() |
| 70 | subject string |
| 71 | body_type BodyType |
| 72 | body string |
| 73 | attachments []Attachment |
| 74 | html Message |
| 75 | text Message |
| 76 | boundary string |
| 77 | } |
| 78 | |
| 79 | pub struct Attachment { |
| 80 | pub: |
| 81 | cid string |
| 82 | filename string |
| 83 | bytes []u8 |
| 84 | } |
| 85 | |
| 86 | // new_client returns a new SMTP client and connects to it |
| 87 | pub fn new_client(config Config) !&Client { |
| 88 | if config.ssl && config.starttls { |
| 89 | return error('Can not use both implicit SSL and STARTTLS') |
| 90 | } |
| 91 | |
| 92 | mut c := &Client{ |
| 93 | Config: config |
| 94 | } |
| 95 | c.reconnect()! |
| 96 | return c |
| 97 | } |
| 98 | |
| 99 | // reconnect reconnects to the SMTP server if the connection was closed |
| 100 | pub fn (mut c Client) reconnect() ! { |
| 101 | if c.is_open { |
| 102 | return error('Already connected to server') |
| 103 | } |
| 104 | |
| 105 | mut conn := net.dial_tcp('${c.server}:${c.port}') or { |
| 106 | return error('Connecting to server failed') |
| 107 | } |
| 108 | if c.timeout != 0 { |
| 109 | conn.set_read_timeout(c.timeout) |
| 110 | conn.set_write_timeout(c.timeout) |
| 111 | } |
| 112 | c.conn = conn |
| 113 | |
| 114 | if c.ssl || c.encrypted { |
| 115 | c.connect_ssl()! |
| 116 | } else { |
| 117 | c.reader = io.new_buffered_reader(reader: c.conn) |
| 118 | } |
| 119 | |
| 120 | c.expect_reply(.ready) or { return error('Received invalid response from server') } |
| 121 | c.send_ehlo() or { return error('Sending EHLO packet failed') } |
| 122 | |
| 123 | if c.starttls && !c.encrypted { |
| 124 | c.send_starttls() or { return error('Sending STARTTLS failed') } |
| 125 | } |
| 126 | |
| 127 | c.send_auth() or { return error('Authenticating to server failed') } |
| 128 | c.is_open = true |
| 129 | } |
| 130 | |
| 131 | // send sends an email |
| 132 | pub fn (mut c Client) send(config Mail) ! { |
| 133 | if !c.is_open { |
| 134 | return error('Disconnected from server') |
| 135 | } |
| 136 | from := if config.from != '' { config.from } else { c.from } |
| 137 | c.send_mailfrom(from) or { return error('Sending mailfrom failed') } |
| 138 | c.send_mailto(config.to) or { return error('Sending mailto failed') } |
| 139 | c.send_data() or { return error('Sending mail data failed') } |
| 140 | c.send_body(Mail{ |
| 141 | ...config |
| 142 | from: from |
| 143 | boundary: rand.uuid_v4() |
| 144 | }) or { return error('Sending mail body failed') } |
| 145 | } |
| 146 | |
| 147 | // quit closes the connection to the server |
| 148 | pub fn (mut c Client) quit() ! { |
| 149 | c.send_str('QUIT\r\n')! |
| 150 | c.expect_reply(.close)! |
| 151 | if c.encrypted { |
| 152 | c.ssl_conn.shutdown()! |
| 153 | } else { |
| 154 | c.conn.close()! |
| 155 | } |
| 156 | c.is_open = false |
| 157 | c.encrypted = false |
| 158 | } |
| 159 | |
| 160 | fn (mut c Client) connect_ssl() ! { |
| 161 | c.ssl_conn = ssl.new_ssl_conn()! |
| 162 | c.ssl_conn.connect(mut c.conn, c.server) or { |
| 163 | return error('Connecting to server using OpenSSL failed: ${err}') |
| 164 | } |
| 165 | |
| 166 | c.reader = io.new_buffered_reader(reader: c.ssl_conn) |
| 167 | c.encrypted = true |
| 168 | } |
| 169 | |
| 170 | // expect_reply checks if the SMTP server replied with the expected reply code |
| 171 | fn (mut c Client) expect_reply(expected ReplyCode) ! { |
| 172 | mut str := '' |
| 173 | for { |
| 174 | str = c.reader or { return error('the Client.reader field is not set') }.read_line()! |
| 175 | if str.len < 4 { |
| 176 | return error('Invalid SMTP response: ${str}') |
| 177 | } |
| 178 | |
| 179 | if str.runes()[3] == `-` { |
| 180 | continue |
| 181 | } else { |
| 182 | break |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | $if smtp_debug ? { |
| 187 | eprintln('\n\n[RECV]') |
| 188 | eprint(str) |
| 189 | } |
| 190 | |
| 191 | if str.len >= 3 { |
| 192 | status := str[..3].int() |
| 193 | if unsafe { ReplyCode(status) } != expected { |
| 194 | return error('Received unexpected status code ${status}, expecting ${expected}') |
| 195 | } |
| 196 | } else { |
| 197 | return error('Received unexpected SMTP data: ${str}') |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | @[inline] |
| 202 | fn (mut c Client) send_str(s string) ! { |
| 203 | $if smtp_debug ? { |
| 204 | eprintln('\n\n[SEND START]') |
| 205 | eprint(s.trim_space()) |
| 206 | eprintln('\n[SEND END]') |
| 207 | } |
| 208 | |
| 209 | if c.encrypted { |
| 210 | c.ssl_conn.write(s.bytes())! |
| 211 | } else { |
| 212 | c.conn.write(s.bytes())! |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | @[inline] |
| 217 | fn (mut c Client) send_ehlo() ! { |
| 218 | c.send_str('EHLO ${c.server}\r\n')! |
| 219 | c.expect_reply(.action_ok)! |
| 220 | } |
| 221 | |
| 222 | @[inline] |
| 223 | fn (mut c Client) send_starttls() ! { |
| 224 | c.send_str('STARTTLS\r\n')! |
| 225 | c.expect_reply(.ready)! |
| 226 | c.connect_ssl()! |
| 227 | } |
| 228 | |
| 229 | @[inline] |
| 230 | fn (mut c Client) send_auth() ! { |
| 231 | if c.username.len == 0 { |
| 232 | return |
| 233 | } |
| 234 | mut sb := strings.new_builder(100) |
| 235 | sb.write_u8(0) |
| 236 | sb.write_string(c.username) |
| 237 | sb.write_u8(0) |
| 238 | sb.write_string(c.password) |
| 239 | a := sb.str() |
| 240 | auth := 'AUTH PLAIN ${base64.encode_str(a)}\r\n' |
| 241 | c.send_str(auth)! |
| 242 | c.expect_reply(.auth_ok)! |
| 243 | } |
| 244 | |
| 245 | // envelope_addr extracts the bare mailbox from an address that may include a |
| 246 | // display name. The SMTP envelope (`MAIL FROM:` / `RCPT TO:`) only accepts a |
| 247 | // bare mailbox (RFC 5321), while `Mail.from`/`Mail.to` may also be written in |
| 248 | // the RFC 5322 `Display Name <[email protected]>` form for the message header. |
| 249 | // |
| 250 | // Only strip an angle-addr wrapper when the input actually ends in `>`; bare |
| 251 | // mailboxes are returned unchanged. When walking for the opening `<`, quoted |
| 252 | // strings are skipped so a quoted local-part like `"a<b"@example.com` is |
| 253 | // preserved intact. |
| 254 | fn envelope_addr(s string) string { |
| 255 | trimmed := s.trim_space() |
| 256 | if !trimmed.ends_with('>') { |
| 257 | return trimmed |
| 258 | } |
| 259 | mut in_quote := false |
| 260 | mut i := 0 |
| 261 | for i < trimmed.len - 1 { |
| 262 | c := trimmed[i] |
| 263 | if c == `"` { |
| 264 | in_quote = !in_quote |
| 265 | } else if in_quote && c == `\\` && i + 1 < trimmed.len { |
| 266 | i++ |
| 267 | } else if c == `<` && !in_quote { |
| 268 | return trimmed[i + 1..trimmed.len - 1] |
| 269 | } |
| 270 | i++ |
| 271 | } |
| 272 | return trimmed |
| 273 | } |
| 274 | |
| 275 | fn (mut c Client) send_mailfrom(from string) ! { |
| 276 | c.send_str('MAIL FROM:<${envelope_addr(from)}>\r\n')! |
| 277 | c.expect_reply(.action_ok)! |
| 278 | } |
| 279 | |
| 280 | fn (mut c Client) send_mailto(to string) ! { |
| 281 | for rcpt in to.split(';') { |
| 282 | c.send_str('RCPT TO:<${envelope_addr(rcpt)}>\r\n')! |
| 283 | c.expect_reply(.action_ok)! |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | fn (mut c Client) send_data() ! { |
| 288 | c.send_str('DATA\r\n')! |
| 289 | c.expect_reply(.mail_start)! |
| 290 | } |
| 291 | |
| 292 | fn (mut c Client) send_body(cfg Mail) ! { |
| 293 | c.send_str(cfg.message_data())! |
| 294 | c.expect_reply(.action_ok)! |
| 295 | } |
| 296 | |
| 297 | fn (cfg &Mail) message_data() string { |
| 298 | date := cfg.date.custom_format('ddd, D MMM YYYY HH:mm ZZ') |
| 299 | nonascii_subject := cfg.subject.bytes().any(it < u8(` `) || it > u8(`~`)) |
| 300 | parts, attachments := cfg.mime_parts() |
| 301 | mut sb := strings.new_builder(200 + cfg.body.len + cfg.text.body.len + cfg.html.body.len + |
| 302 | (cfg.attachments.len + cfg.text.attachments.len + cfg.html.attachments.len) * 200) |
| 303 | sb.write_string('From: ${cfg.from}\r\n') |
| 304 | sb.write_string('To: <${cfg.to.split(';').join('>; <')}>\r\n') |
| 305 | sb.write_string('Cc: <${cfg.cc.split(';').join('>; <')}>\r\n') |
| 306 | sb.write_string('Bcc: <${cfg.bcc.split(';').join('>; <')}>\r\n') |
| 307 | sb.write_string('Date: ${date}\r\n') |
| 308 | if nonascii_subject { |
| 309 | // handle UTF-8 subjects according RFC 1342 |
| 310 | sb.write_string('Subject: =?utf-8?B?' + base64.encode_str(cfg.subject) + '?=\r\n') |
| 311 | } else { |
| 312 | sb.write_string('Subject: ${cfg.subject}\r\n') |
| 313 | } |
| 314 | if parts.len > 1 || attachments.len > 0 { |
| 315 | sb.write_string('MIME-Version: 1.0\r\n') |
| 316 | } |
| 317 | |
| 318 | boundary := cfg.mime_boundary() |
| 319 | if parts.len > 1 && attachments.len > 0 { |
| 320 | alternative_boundary := '${boundary}-alternative' |
| 321 | write_multipart_header(mut sb, 'multipart/mixed', boundary) |
| 322 | write_multipart_boundary(mut sb, boundary) |
| 323 | write_multipart_header(mut sb, 'multipart/alternative', alternative_boundary) |
| 324 | for part in parts { |
| 325 | write_multipart_boundary(mut sb, alternative_boundary) |
| 326 | write_message_part(mut sb, part) |
| 327 | } |
| 328 | write_multipart_end(mut sb, alternative_boundary) |
| 329 | write_attachments(mut sb, attachments, boundary) |
| 330 | } else if parts.len > 1 { |
| 331 | write_multipart_header(mut sb, 'multipart/alternative', boundary) |
| 332 | for part in parts { |
| 333 | write_multipart_boundary(mut sb, boundary) |
| 334 | write_message_part(mut sb, part) |
| 335 | } |
| 336 | write_multipart_end(mut sb, boundary) |
| 337 | } else if attachments.len > 0 { |
| 338 | write_multipart_header(mut sb, 'multipart/mixed', boundary) |
| 339 | write_multipart_boundary(mut sb, boundary) |
| 340 | write_message_part(mut sb, parts[0]) |
| 341 | write_attachments(mut sb, attachments, boundary) |
| 342 | } else { |
| 343 | write_message_part(mut sb, parts[0]) |
| 344 | } |
| 345 | sb.write_string('.\r\n') |
| 346 | return sb.str() |
| 347 | } |
| 348 | |
| 349 | struct MimePart { |
| 350 | body_type BodyType |
| 351 | body string |
| 352 | } |
| 353 | |
| 354 | fn (cfg &Mail) mime_parts() ([]MimePart, []Attachment) { |
| 355 | if cfg.text.body != '' || cfg.html.body != '' { |
| 356 | mut parts := []MimePart{cap: 2} |
| 357 | mut attachments := []Attachment{cap: cfg.text.attachments.len + cfg.html.attachments.len} |
| 358 | if cfg.text.body != '' { |
| 359 | parts << MimePart{ |
| 360 | body_type: .text |
| 361 | body: cfg.text.body |
| 362 | } |
| 363 | } |
| 364 | attachments << cfg.text.attachments |
| 365 | if cfg.html.body != '' { |
| 366 | parts << MimePart{ |
| 367 | body_type: .html |
| 368 | body: cfg.html.body |
| 369 | } |
| 370 | } |
| 371 | attachments << cfg.html.attachments |
| 372 | return parts, attachments |
| 373 | } |
| 374 | return [MimePart{ |
| 375 | body_type: cfg.body_type |
| 376 | body: cfg.body |
| 377 | }], cfg.attachments |
| 378 | } |
| 379 | |
| 380 | fn (cfg &Mail) mime_boundary() string { |
| 381 | if cfg.boundary != '' { |
| 382 | return cfg.boundary |
| 383 | } |
| 384 | return 'v-smtp-boundary' |
| 385 | } |
| 386 | |
| 387 | fn write_multipart_header(mut sb strings.Builder, multipart_type string, boundary string) { |
| 388 | sb.write_string('Content-Type: ${multipart_type}; boundary="${boundary}"\r\n\r\n') |
| 389 | } |
| 390 | |
| 391 | fn write_multipart_boundary(mut sb strings.Builder, boundary string) { |
| 392 | sb.write_string('--${boundary}\r\n') |
| 393 | } |
| 394 | |
| 395 | fn write_multipart_end(mut sb strings.Builder, boundary string) { |
| 396 | sb.write_string('--${boundary}--\r\n') |
| 397 | } |
| 398 | |
| 399 | fn write_message_part(mut sb strings.Builder, part MimePart) { |
| 400 | if part.body_type == .html { |
| 401 | sb.write_string('Content-Type: text/html; charset=UTF-8\r\n') |
| 402 | } else { |
| 403 | sb.write_string('Content-Type: text/plain; charset=UTF-8\r\n') |
| 404 | } |
| 405 | sb.write_string('Content-Transfer-Encoding: base64\r\n\r\n') |
| 406 | sb.write_string(fold_base64(base64.encode_str(part.body))) |
| 407 | sb.write_string('\r\n') |
| 408 | } |
| 409 | |
| 410 | fn write_attachments(mut sb strings.Builder, attachments []Attachment, boundary string) { |
| 411 | for attachment in attachments { |
| 412 | write_multipart_boundary(mut sb, boundary) |
| 413 | sb.write_string(attachment.to_string()) |
| 414 | sb.write_string('\r\n') |
| 415 | } |
| 416 | write_multipart_end(mut sb, boundary) |
| 417 | } |
| 418 | |
| 419 | fn (a &Attachment) to_string() string { |
| 420 | crlf := '\r\n' |
| 421 | cid := if a.cid != '' { |
| 422 | 'Content-ID: <${a.cid}>${crlf}' |
| 423 | } else { |
| 424 | '' |
| 425 | } |
| 426 | return 'Content-Type: application/octet-stream${crlf}${cid}Content-Transfer-Encoding: base64${crlf}Content-Disposition: attachment; filename="${a.filename}"${crlf}${crlf}${fold_base64(base64.encode(a.bytes))}' |
| 427 | } |
| 428 | |
| 429 | fn fold_base64(encoded string) string { |
| 430 | if encoded.len <= 76 { |
| 431 | return encoded |
| 432 | } |
| 433 | mut sb := strings.new_builder(encoded.len + encoded.len / 76 * 2) |
| 434 | for start := 0; start < encoded.len; start += 76 { |
| 435 | end := if start + 76 < encoded.len { start + 76 } else { encoded.len } |
| 436 | sb.write_string(encoded[start..end]) |
| 437 | if end < encoded.len { |
| 438 | sb.write_string('\r\n') |
| 439 | } |
| 440 | } |
| 441 | return sb.str() |
| 442 | } |
| 443 | |