internal/mail/mail.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/mail/mail.go history · blame · raw

112 lines · 3036 bytes

4 symbols in this file
  1// Package mail sends transactional email over SMTP: verification codes and
  2// invites. The connection is encrypted with STARTTLS, or with TLS from the
  3// first byte when mail.tls = "implicit"; a relay that offers neither gets
  4// nothing unless mail.require_tls is off. PLAIN auth when credentials are
  5// configured.
  6package mail
  7
  8import (
  9	"crypto/tls"
 10	"crypto/x509"
 11	"fmt"
 12	"net"
 13	"net/smtp"
 14	"strings"
 15	"time"
 16
 17	"gitbay.org/gitbay/internal/config"
 18)
 19
 20// rootCAs verifies the relay's certificate; nil is the system pool.
 21var rootCAs *x509.CertPool
 22
 23// Send delivers one plain-text message. cfg.Mail.SMTPHost is host:port.
 24func Send(cfg config.Config, to, subject, body string) error {
 25	return SendReplyTo(cfg, to, "", subject, body)
 26}
 27
 28// SendReplyTo is Send with a Reply-To header, left out when replyTo is
 29// empty.
 30func SendReplyTo(cfg config.Config, to, replyTo, subject, body string) error {
 31	m := cfg.Mail
 32	if m.SMTPHost == "" || m.From == "" {
 33		return fmt.Errorf("[mail] smtp_host and from must be configured")
 34	}
 35	if strings.ContainsAny(replyTo, "\r\n") {
 36		return fmt.Errorf("reply-to address contains a line break")
 37	}
 38	header := ""
 39	if replyTo != "" {
 40		header = "Reply-To: " + replyTo + "\n"
 41	}
 42	implicit := m.TLS == "implicit"
 43	host := m.SMTPHost
 44	if !strings.Contains(host, ":") {
 45		if implicit {
 46			host += ":465"
 47		} else {
 48			host += ":587"
 49		}
 50	}
 51	hostname, _, _ := net.SplitHostPort(host)
 52	tlsCfg := &tls.Config{ServerName: hostname, RootCAs: rootCAs}
 53
 54	msg := strings.NewReplacer("\n", "\r\n").Replace(fmt.Sprintf(
 55		"From: %s\nTo: %s\n%sSubject: %s\nDate: %s\nMIME-Version: 1.0\nContent-Type: text/plain; charset=utf-8\n\n%s\n",
 56		m.From, to, header, subject, time.Now().Format(time.RFC1123Z), body))
 57
 58	c, err := dial(host, hostname, implicit, tlsCfg)
 59	if err != nil {
 60		return fmt.Errorf("smtp dial %s: %w", host, err)
 61	}
 62	defer c.Close()
 63	if !implicit {
 64		if ok, _ := c.Extension("STARTTLS"); ok {
 65			if err := c.StartTLS(tlsCfg); err != nil {
 66				return fmt.Errorf("starttls: %w", err)
 67			}
 68		} else if m.TLSRequired() {
 69			return fmt.Errorf("%s does not offer STARTTLS and mail.require_tls is on; not sending in clear", host)
 70		}
 71	}
 72	if m.SMTPUser != "" {
 73		if err := c.Auth(smtp.PlainAuth("", m.SMTPUser, m.SMTPPass, hostname)); err != nil {
 74			return fmt.Errorf("smtp auth: %w", err)
 75		}
 76	}
 77	if err := c.Mail(m.From); err != nil {
 78		return err
 79	}
 80	if err := c.Rcpt(to); err != nil {
 81		return err
 82	}
 83	w, err := c.Data()
 84	if err != nil {
 85		return err
 86	}
 87	if _, err := w.Write([]byte(msg)); err != nil {
 88		return err
 89	}
 90	if err := w.Close(); err != nil {
 91		return err
 92	}
 93	return c.Quit()
 94}
 95
 96// dial opens the SMTP session: plain TCP for STARTTLS, or TLS from the
 97// first byte.
 98func dial(addr, hostname string, implicit bool, tlsCfg *tls.Config) (*smtp.Client, error) {
 99	if !implicit {
100		return smtp.Dial(addr)
101	}
102	conn, err := tls.Dial("tcp", addr, tlsCfg)
103	if err != nil {
104		return nil, err
105	}
106	c, err := smtp.NewClient(conn, hostname)
107	if err != nil {
108		conn.Close()
109		return nil, err
110	}
111	return c, nil
112}