accounts/mail.go (3017 bytes)
1 package main 2 3 import ( 4 "bytes" 5 "fmt" 6 "io" 7 "mime" 8 "mime/quotedprintable" 9 "net" 10 "net/mail" 11 "net/smtp" 12 "strings" 13 "time" 14 ) 15 16 // message is one plain text email to one address. 17 type message struct { 18 To string 19 Subject string 20 Body string 21 // Unsubscribe is a URL for the List-Unsubscribe header (RFC 2369) and 22 // for one-click unsubscribe (RFC 8058). It is empty for sign-in email. 23 Unsubscribe string 24 } 25 26 type mailer interface { 27 Send(m message) error 28 } 29 30 // smtpMailer sends through any SMTP server: a local MTA, or the SMTP 31 // endpoint of a hosted provider (Resend, Postmark, Amazon SES). 32 type smtpMailer struct { 33 Addr string // host:port 34 Username string // empty for a server that needs no sign-in 35 Password string 36 From *mail.Address 37 } 38 39 // Send uses STARTTLS when the server offers it. net/smtp PlainAuth does 40 // not send the password on a connection without TLS, except to localhost. 41 func (s smtpMailer) Send(m message) error { 42 var auth smtp.Auth 43 if s.Username != "" { 44 host, _, _ := net.SplitHostPort(s.Addr) 45 auth = smtp.PlainAuth("", s.Username, s.Password, host) 46 } 47 data, err := formatMessage(s.From, m, time.Now()) 48 if err != nil { 49 return err 50 } 51 return smtp.SendMail(s.Addr, auth, s.From.Address, []string{m.To}, data) 52 } 53 54 // logMailer writes each message to w. Use it only for development: the 55 // messages contain sign-in links. 56 type logMailer struct{ W io.Writer } 57 58 func (l logMailer) Send(m message) error { 59 _, err := fmt.Fprintf(l.W, "--- mail to %s\nSubject: %s\n\n%s\n---\n", m.To, m.Subject, m.Body) 60 return err 61 } 62 63 // formatMessage makes an RFC 5322 message. The subject is encoded as an 64 // RFC 2047 word, and the body as quoted-printable, so that non-ASCII text 65 // and long lines arrive intact. 66 func formatMessage(from *mail.Address, m message, now time.Time) ([]byte, error) { 67 to, err := mail.ParseAddress(m.To) 68 if err != nil { 69 return nil, err 70 } 71 // A line break in a header value would start a new header. 72 subject := strings.NewReplacer("\r", " ", "\n", " ").Replace(m.Subject) 73 domain := from.Address[strings.LastIndex(from.Address, "@")+1:] 74 75 var b bytes.Buffer 76 h := func(k, v string) { fmt.Fprintf(&b, "%s: %s\r\n", k, v) } 77 h("From", from.String()) 78 h("To", to.String()) 79 h("Subject", mime.QEncoding.Encode("utf-8", subject)) 80 h("Date", now.Format(time.RFC1123Z)) 81 h("Message-ID", "<"+strings.ToLower(newToken())+"@"+domain+">") 82 h("MIME-Version", "1.0") 83 h("Content-Type", "text/plain; charset=utf-8") 84 h("Content-Transfer-Encoding", "quoted-printable") 85 if m.Unsubscribe != "" { 86 h("List-Unsubscribe", "<"+m.Unsubscribe+">") 87 h("List-Unsubscribe-Post", "List-Unsubscribe=One-Click") 88 } 89 b.WriteString("\r\n") 90 qp := quotedprintable.NewWriter(&b) 91 // Email lines end with CRLF. A CR or LF alone becomes CRLF. 92 crlf := strings.NewReplacer("\r\n", "\r\n", "\r", "\r\n", "\n", "\r\n") 93 if _, err := qp.Write([]byte(crlf.Replace(m.Body))); err != nil { 94 return nil, err 95 } 96 if err := qp.Close(); err != nil { 97 return nil, err 98 } 99 return b.Bytes(), nil 100 }