v2 / vlib / net / smtp / smtp.v
442 lines · 395 sloc · 10.92 KB · 58aba9648c741f274928fe3cf66d6aa8f146daff
Raw
1module smtp
2
3/*
4*
5* smtp module
6* Created by: nedimf (07/2020)
7*/
8import net
9import net.ssl
10import encoding.base64
11import strings
12import time
13import io
14import rand
15
16const recv_size = 128
17
18enum ReplyCode {
19 ready = 220
20 close = 221
21 auth_ok = 235
22 action_ok = 250
23 mail_start = 354
24}
25
26pub enum BodyType {
27 text
28 html
29}
30
31// Message stores one body variant and optional attachments for a Mail.
32pub struct Message {
33pub:
34 body string
35 attachments []Attachment
36}
37
38// Config stores the settings used to connect a new SMTP client.
39pub struct Config {
40pub:
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
51pub struct Client {
52 Config
53mut:
54 conn net.TcpConn
55 ssl_conn &ssl.SSLConn = unsafe { nil }
56 reader ?&io.BufferedReader
57pub mut:
58 is_open bool
59 encrypted bool
60}
61
62// Mail stores the message headers and MIME payload sent by Client.send.
63pub struct Mail {
64pub:
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
79pub struct Attachment {
80pub:
81 cid string
82 filename string
83 bytes []u8
84}
85
86// new_client returns a new SMTP client and connects to it
87pub 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
100pub 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
132pub 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
148pub 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
160fn (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
171fn (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]
202fn (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]
217fn (mut c Client) send_ehlo() ! {
218 c.send_str('EHLO ${c.server}\r\n')!
219 c.expect_reply(.action_ok)!
220}
221
222@[inline]
223fn (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]
230fn (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.
254fn 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
275fn (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
280fn (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
287fn (mut c Client) send_data() ! {
288 c.send_str('DATA\r\n')!
289 c.expect_reply(.mail_start)!
290}
291
292fn (mut c Client) send_body(cfg Mail) ! {
293 c.send_str(cfg.message_data())!
294 c.expect_reply(.action_ok)!
295}
296
297fn (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
349struct MimePart {
350 body_type BodyType
351 body string
352}
353
354fn (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
380fn (cfg &Mail) mime_boundary() string {
381 if cfg.boundary != '' {
382 return cfg.boundary
383 }
384 return 'v-smtp-boundary'
385}
386
387fn 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
391fn write_multipart_boundary(mut sb strings.Builder, boundary string) {
392 sb.write_string('--${boundary}\r\n')
393}
394
395fn write_multipart_end(mut sb strings.Builder, boundary string) {
396 sb.write_string('--${boundary}--\r\n')
397}
398
399fn 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
410fn 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
419fn (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
429fn 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