Initial project import
This commit is contained in:
commit
23f2d29e32
11957 files changed
+1754577
No files matched your search
@@ -0,0 +1,442 @@
|
||||
module smtp
|
||||
|
||||
/*
|
||||
*
|
||||
* smtp module
|
||||
* Created by: nedimf (07/2020)
|
||||
*/
|
||||
import net
|
||||
import net.ssl
|
||||
import encoding.base64
|
||||
import strings
|
||||
import time
|
||||
import io
|
||||
import rand
|
||||
|
||||
const recv_size = 128
|
||||
|
||||
enum ReplyCode {
|
||||
ready = 220
|
||||
close = 221
|
||||
auth_ok = 235
|
||||
action_ok = 250
|
||||
mail_start = 354
|
||||
}
|
||||
|
||||
pub enum BodyType {
|
||||
text
|
||||
html
|
||||
}
|
||||
|
||||
// Message stores one body variant and optional attachments for a Mail.
|
||||
pub struct Message {
|
||||
pub:
|
||||
body string
|
||||
attachments []Attachment
|
||||
}
|
||||
|
||||
// Config stores the settings used to connect a new SMTP client.
|
||||
pub struct Config {
|
||||
pub:
|
||||
server string
|
||||
port int = 25
|
||||
username string
|
||||
password string
|
||||
from string
|
||||
ssl bool
|
||||
starttls bool
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
Config
|
||||
mut:
|
||||
conn net.TcpConn
|
||||
ssl_conn &ssl.SSLConn = unsafe { nil }
|
||||
reader ?&io.BufferedReader
|
||||
pub mut:
|
||||
is_open bool
|
||||
encrypted bool
|
||||
}
|
||||
|
||||
// Mail stores the message headers and MIME payload sent by Client.send.
|
||||
pub struct Mail {
|
||||
pub:
|
||||
from string
|
||||
to string
|
||||
cc string
|
||||
bcc string
|
||||
date time.Time = time.now()
|
||||
subject string
|
||||
body_type BodyType
|
||||
body string
|
||||
attachments []Attachment
|
||||
html Message
|
||||
text Message
|
||||
boundary string
|
||||
}
|
||||
|
||||
pub struct Attachment {
|
||||
pub:
|
||||
cid string
|
||||
filename string
|
||||
bytes []u8
|
||||
}
|
||||
|
||||
// new_client returns a new SMTP client and connects to it
|
||||
pub fn new_client(config Config) !&Client {
|
||||
if config.ssl && config.starttls {
|
||||
return error('Can not use both implicit SSL and STARTTLS')
|
||||
}
|
||||
|
||||
mut c := &Client{
|
||||
Config: config
|
||||
}
|
||||
c.reconnect()!
|
||||
return c
|
||||
}
|
||||
|
||||
// reconnect reconnects to the SMTP server if the connection was closed
|
||||
pub fn (mut c Client) reconnect() ! {
|
||||
if c.is_open {
|
||||
return error('Already connected to server')
|
||||
}
|
||||
|
||||
mut conn := net.dial_tcp('${c.server}:${c.port}') or {
|
||||
return error('Connecting to server failed')
|
||||
}
|
||||
if c.timeout != 0 {
|
||||
conn.set_read_timeout(c.timeout)
|
||||
conn.set_write_timeout(c.timeout)
|
||||
}
|
||||
c.conn = conn
|
||||
|
||||
if c.ssl || c.encrypted {
|
||||
c.connect_ssl()!
|
||||
} else {
|
||||
c.reader = io.new_buffered_reader(reader: c.conn)
|
||||
}
|
||||
|
||||
c.expect_reply(.ready) or { return error('Received invalid response from server') }
|
||||
c.send_ehlo() or { return error('Sending EHLO packet failed') }
|
||||
|
||||
if c.starttls && !c.encrypted {
|
||||
c.send_starttls() or { return error('Sending STARTTLS failed') }
|
||||
}
|
||||
|
||||
c.send_auth() or { return error('Authenticating to server failed') }
|
||||
c.is_open = true
|
||||
}
|
||||
|
||||
// send sends an email
|
||||
pub fn (mut c Client) send(config Mail) ! {
|
||||
if !c.is_open {
|
||||
return error('Disconnected from server')
|
||||
}
|
||||
from := if config.from != '' { config.from } else { c.from }
|
||||
c.send_mailfrom(from) or { return error('Sending mailfrom failed') }
|
||||
c.send_mailto(config.to) or { return error('Sending mailto failed') }
|
||||
c.send_data() or { return error('Sending mail data failed') }
|
||||
c.send_body(Mail{
|
||||
...config
|
||||
from: from
|
||||
boundary: rand.uuid_v4()
|
||||
}) or { return error('Sending mail body failed') }
|
||||
}
|
||||
|
||||
// quit closes the connection to the server
|
||||
pub fn (mut c Client) quit() ! {
|
||||
c.send_str('QUIT\r\n')!
|
||||
c.expect_reply(.close)!
|
||||
if c.encrypted {
|
||||
c.ssl_conn.shutdown()!
|
||||
} else {
|
||||
c.conn.close()!
|
||||
}
|
||||
c.is_open = false
|
||||
c.encrypted = false
|
||||
}
|
||||
|
||||
fn (mut c Client) connect_ssl() ! {
|
||||
c.ssl_conn = ssl.new_ssl_conn()!
|
||||
c.ssl_conn.connect(mut c.conn, c.server) or {
|
||||
return error('Connecting to server using OpenSSL failed: ${err}')
|
||||
}
|
||||
|
||||
c.reader = io.new_buffered_reader(reader: c.ssl_conn)
|
||||
c.encrypted = true
|
||||
}
|
||||
|
||||
// expect_reply checks if the SMTP server replied with the expected reply code
|
||||
fn (mut c Client) expect_reply(expected ReplyCode) ! {
|
||||
mut str := ''
|
||||
for {
|
||||
str = c.reader or { return error('the Client.reader field is not set') }.read_line()!
|
||||
if str.len < 4 {
|
||||
return error('Invalid SMTP response: ${str}')
|
||||
}
|
||||
|
||||
if str.runes()[3] == `-` {
|
||||
continue
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
$if smtp_debug ? {
|
||||
eprintln('\n\n[RECV]')
|
||||
eprint(str)
|
||||
}
|
||||
|
||||
if str.len >= 3 {
|
||||
status := str[..3].int()
|
||||
if unsafe { ReplyCode(status) } != expected {
|
||||
return error('Received unexpected status code ${status}, expecting ${expected}')
|
||||
}
|
||||
} else {
|
||||
return error('Received unexpected SMTP data: ${str}')
|
||||
}
|
||||
}
|
||||
|
||||
@[inline]
|
||||
fn (mut c Client) send_str(s string) ! {
|
||||
$if smtp_debug ? {
|
||||
eprintln('\n\n[SEND START]')
|
||||
eprint(s.trim_space())
|
||||
eprintln('\n[SEND END]')
|
||||
}
|
||||
|
||||
if c.encrypted {
|
||||
c.ssl_conn.write(s.bytes())!
|
||||
} else {
|
||||
c.conn.write(s.bytes())!
|
||||
}
|
||||
}
|
||||
|
||||
@[inline]
|
||||
fn (mut c Client) send_ehlo() ! {
|
||||
c.send_str('EHLO ${c.server}\r\n')!
|
||||
c.expect_reply(.action_ok)!
|
||||
}
|
||||
|
||||
@[inline]
|
||||
fn (mut c Client) send_starttls() ! {
|
||||
c.send_str('STARTTLS\r\n')!
|
||||
c.expect_reply(.ready)!
|
||||
c.connect_ssl()!
|
||||
}
|
||||
|
||||
@[inline]
|
||||
fn (mut c Client) send_auth() ! {
|
||||
if c.username.len == 0 {
|
||||
return
|
||||
}
|
||||
mut sb := strings.new_builder(100)
|
||||
sb.write_u8(0)
|
||||
sb.write_string(c.username)
|
||||
sb.write_u8(0)
|
||||
sb.write_string(c.password)
|
||||
a := sb.str()
|
||||
auth := 'AUTH PLAIN ${base64.encode_str(a)}\r\n'
|
||||
c.send_str(auth)!
|
||||
c.expect_reply(.auth_ok)!
|
||||
}
|
||||
|
||||
// envelope_addr extracts the bare mailbox from an address that may include a
|
||||
// display name. The SMTP envelope (`MAIL FROM:` / `RCPT TO:`) only accepts a
|
||||
// bare mailbox (RFC 5321), while `Mail.from`/`Mail.to` may also be written in
|
||||
// the RFC 5322 `Display Name <addr@example.com>` form for the message header.
|
||||
//
|
||||
// Only strip an angle-addr wrapper when the input actually ends in `>`; bare
|
||||
// mailboxes are returned unchanged. When walking for the opening `<`, quoted
|
||||
// strings are skipped so a quoted local-part like `"a<b"@example.com` is
|
||||
// preserved intact.
|
||||
fn envelope_addr(s string) string {
|
||||
trimmed := s.trim_space()
|
||||
if !trimmed.ends_with('>') {
|
||||
return trimmed
|
||||
}
|
||||
mut in_quote := false
|
||||
mut i := 0
|
||||
for i < trimmed.len - 1 {
|
||||
c := trimmed[i]
|
||||
if c == `"` {
|
||||
in_quote = !in_quote
|
||||
} else if in_quote && c == `\\` && i + 1 < trimmed.len {
|
||||
i++
|
||||
} else if c == `<` && !in_quote {
|
||||
return trimmed[i + 1..trimmed.len - 1]
|
||||
}
|
||||
i++
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
fn (mut c Client) send_mailfrom(from string) ! {
|
||||
c.send_str('MAIL FROM:<${envelope_addr(from)}>\r\n')!
|
||||
c.expect_reply(.action_ok)!
|
||||
}
|
||||
|
||||
fn (mut c Client) send_mailto(to string) ! {
|
||||
for rcpt in to.split(';') {
|
||||
c.send_str('RCPT TO:<${envelope_addr(rcpt)}>\r\n')!
|
||||
c.expect_reply(.action_ok)!
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut c Client) send_data() ! {
|
||||
c.send_str('DATA\r\n')!
|
||||
c.expect_reply(.mail_start)!
|
||||
}
|
||||
|
||||
fn (mut c Client) send_body(cfg Mail) ! {
|
||||
c.send_str(cfg.message_data())!
|
||||
c.expect_reply(.action_ok)!
|
||||
}
|
||||
|
||||
fn (cfg &Mail) message_data() string {
|
||||
date := cfg.date.custom_format('ddd, D MMM YYYY HH:mm ZZ')
|
||||
nonascii_subject := cfg.subject.bytes().any(it < u8(` `) || it > u8(`~`))
|
||||
parts, attachments := cfg.mime_parts()
|
||||
mut sb := strings.new_builder(200 + cfg.body.len + cfg.text.body.len + cfg.html.body.len +
|
||||
(cfg.attachments.len + cfg.text.attachments.len + cfg.html.attachments.len) * 200)
|
||||
sb.write_string('From: ${cfg.from}\r\n')
|
||||
sb.write_string('To: <${cfg.to.split(';').join('>; <')}>\r\n')
|
||||
sb.write_string('Cc: <${cfg.cc.split(';').join('>; <')}>\r\n')
|
||||
sb.write_string('Bcc: <${cfg.bcc.split(';').join('>; <')}>\r\n')
|
||||
sb.write_string('Date: ${date}\r\n')
|
||||
if nonascii_subject {
|
||||
// handle UTF-8 subjects according RFC 1342
|
||||
sb.write_string('Subject: =?utf-8?B?' + base64.encode_str(cfg.subject) + '?=\r\n')
|
||||
} else {
|
||||
sb.write_string('Subject: ${cfg.subject}\r\n')
|
||||
}
|
||||
if parts.len > 1 || attachments.len > 0 {
|
||||
sb.write_string('MIME-Version: 1.0\r\n')
|
||||
}
|
||||
|
||||
boundary := cfg.mime_boundary()
|
||||
if parts.len > 1 && attachments.len > 0 {
|
||||
alternative_boundary := '${boundary}-alternative'
|
||||
write_multipart_header(mut sb, 'multipart/mixed', boundary)
|
||||
write_multipart_boundary(mut sb, boundary)
|
||||
write_multipart_header(mut sb, 'multipart/alternative', alternative_boundary)
|
||||
for part in parts {
|
||||
write_multipart_boundary(mut sb, alternative_boundary)
|
||||
write_message_part(mut sb, part)
|
||||
}
|
||||
write_multipart_end(mut sb, alternative_boundary)
|
||||
write_attachments(mut sb, attachments, boundary)
|
||||
} else if parts.len > 1 {
|
||||
write_multipart_header(mut sb, 'multipart/alternative', boundary)
|
||||
for part in parts {
|
||||
write_multipart_boundary(mut sb, boundary)
|
||||
write_message_part(mut sb, part)
|
||||
}
|
||||
write_multipart_end(mut sb, boundary)
|
||||
} else if attachments.len > 0 {
|
||||
write_multipart_header(mut sb, 'multipart/mixed', boundary)
|
||||
write_multipart_boundary(mut sb, boundary)
|
||||
write_message_part(mut sb, parts[0])
|
||||
write_attachments(mut sb, attachments, boundary)
|
||||
} else {
|
||||
write_message_part(mut sb, parts[0])
|
||||
}
|
||||
sb.write_string('.\r\n')
|
||||
return sb.str()
|
||||
}
|
||||
|
||||
struct MimePart {
|
||||
body_type BodyType
|
||||
body string
|
||||
}
|
||||
|
||||
fn (cfg &Mail) mime_parts() ([]MimePart, []Attachment) {
|
||||
if cfg.text.body != '' || cfg.html.body != '' {
|
||||
mut parts := []MimePart{cap: 2}
|
||||
mut attachments := []Attachment{cap: cfg.text.attachments.len + cfg.html.attachments.len}
|
||||
if cfg.text.body != '' {
|
||||
parts << MimePart{
|
||||
body_type: .text
|
||||
body: cfg.text.body
|
||||
}
|
||||
}
|
||||
attachments << cfg.text.attachments
|
||||
if cfg.html.body != '' {
|
||||
parts << MimePart{
|
||||
body_type: .html
|
||||
body: cfg.html.body
|
||||
}
|
||||
}
|
||||
attachments << cfg.html.attachments
|
||||
return parts, attachments
|
||||
}
|
||||
return [MimePart{
|
||||
body_type: cfg.body_type
|
||||
body: cfg.body
|
||||
}], cfg.attachments
|
||||
}
|
||||
|
||||
fn (cfg &Mail) mime_boundary() string {
|
||||
if cfg.boundary != '' {
|
||||
return cfg.boundary
|
||||
}
|
||||
return 'v-smtp-boundary'
|
||||
}
|
||||
|
||||
fn write_multipart_header(mut sb strings.Builder, multipart_type string, boundary string) {
|
||||
sb.write_string('Content-Type: ${multipart_type}; boundary="${boundary}"\r\n\r\n')
|
||||
}
|
||||
|
||||
fn write_multipart_boundary(mut sb strings.Builder, boundary string) {
|
||||
sb.write_string('--${boundary}\r\n')
|
||||
}
|
||||
|
||||
fn write_multipart_end(mut sb strings.Builder, boundary string) {
|
||||
sb.write_string('--${boundary}--\r\n')
|
||||
}
|
||||
|
||||
fn write_message_part(mut sb strings.Builder, part MimePart) {
|
||||
if part.body_type == .html {
|
||||
sb.write_string('Content-Type: text/html; charset=UTF-8\r\n')
|
||||
} else {
|
||||
sb.write_string('Content-Type: text/plain; charset=UTF-8\r\n')
|
||||
}
|
||||
sb.write_string('Content-Transfer-Encoding: base64\r\n\r\n')
|
||||
sb.write_string(fold_base64(base64.encode_str(part.body)))
|
||||
sb.write_string('\r\n')
|
||||
}
|
||||
|
||||
fn write_attachments(mut sb strings.Builder, attachments []Attachment, boundary string) {
|
||||
for attachment in attachments {
|
||||
write_multipart_boundary(mut sb, boundary)
|
||||
sb.write_string(attachment.to_string())
|
||||
sb.write_string('\r\n')
|
||||
}
|
||||
write_multipart_end(mut sb, boundary)
|
||||
}
|
||||
|
||||
fn (a &Attachment) to_string() string {
|
||||
crlf := '\r\n'
|
||||
cid := if a.cid != '' {
|
||||
'Content-ID: <${a.cid}>${crlf}'
|
||||
} else {
|
||||
''
|
||||
}
|
||||
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))}'
|
||||
}
|
||||
|
||||
fn fold_base64(encoded string) string {
|
||||
if encoded.len <= 76 {
|
||||
return encoded
|
||||
}
|
||||
mut sb := strings.new_builder(encoded.len + encoded.len / 76 * 2)
|
||||
for start := 0; start < encoded.len; start += 76 {
|
||||
end := if start + 76 < encoded.len { start + 76 } else { encoded.len }
|
||||
sb.write_string(encoded[start..end])
|
||||
if end < encoded.len {
|
||||
sb.write_string('\r\n')
|
||||
}
|
||||
}
|
||||
return sb.str()
|
||||
}
|
||||
Reference in new issue
Block a user