package mail import ( "bytes" "crypto/tls" "fmt" "io" "time" gomail "github.com/emersion/go-message/mail" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" ) // SMTPConfig is the connection/auth detail for an account's send endpoint. type SMTPConfig struct { Host string Port int Security string // tls | starttls Username string Password string } // OutgoingMessage is a plain-text message (with optional attachments) to send. // InReplyTo and References hold bare Message-IDs (no angle brackets) and, when // set, produce threading headers so a reply chains onto its source. type OutgoingMessage struct { From string To []string Cc []string Bcc []string Subject string BodyText string Attachments []Attachment InReplyTo string References []string Date time.Time } // Recipients returns the full envelope recipient set: to + cc + bcc. func (m OutgoingMessage) Recipients() []string { out := make([]string, 0, len(m.To)+len(m.Cc)+len(m.Bcc)) out = append(out, m.To...) out = append(out, m.Cc...) out = append(out, m.Bcc...) return out } func addrList(addrs []string) []*gomail.Address { out := make([]*gomail.Address, 0, len(addrs)) for _, a := range addrs { if parsed, err := gomail.ParseAddress(a); err == nil { out = append(out, parsed) } else { out = append(out, &gomail.Address{Address: a}) } } return out } // BuildMIME renders an OutgoingMessage to RFC822 bytes: a plain-text inline part // plus any attachments. Bcc recipients are intentionally omitted from the headers // (they travel only in the SMTP envelope). func BuildMIME(m OutgoingMessage) ([]byte, error) { var h gomail.Header if m.Date.IsZero() { m.Date = time.Now() } h.SetDate(m.Date) h.SetAddressList("From", addrList([]string{m.From})) if len(m.To) > 0 { h.SetAddressList("To", addrList(m.To)) } if len(m.Cc) > 0 { h.SetAddressList("Cc", addrList(m.Cc)) } h.SetSubject(m.Subject) if m.InReplyTo != "" { h.SetMsgIDList("In-Reply-To", []string{m.InReplyTo}) } if len(m.References) > 0 { h.SetMsgIDList("References", m.References) } if err := h.GenerateMessageID(); err != nil { return nil, fmt.Errorf("generate message-id: %w", err) } var buf bytes.Buffer w, err := gomail.CreateWriter(&buf, h) if err != nil { return nil, fmt.Errorf("create mail writer: %w", err) } tw, err := w.CreateInline() if err != nil { return nil, fmt.Errorf("create inline: %w", err) } var th gomail.InlineHeader th.Set("Content-Type", "text/plain; charset=utf-8") pw, err := tw.CreatePart(th) if err != nil { return nil, fmt.Errorf("create text part: %w", err) } if _, err := io.WriteString(pw, m.BodyText); err != nil { return nil, fmt.Errorf("write body: %w", err) } if err := pw.Close(); err != nil { return nil, err } if err := tw.Close(); err != nil { return nil, err } for _, a := range m.Attachments { var ah gomail.AttachmentHeader if a.MIME != "" { ah.Set("Content-Type", a.MIME) } ah.SetFilename(a.Name) aw, err := w.CreateAttachment(ah) if err != nil { return nil, fmt.Errorf("create attachment %q: %w", a.Name, err) } if _, err := aw.Write(a.Content); err != nil { return nil, fmt.Errorf("write attachment %q: %w", a.Name, err) } if err := aw.Close(); err != nil { return nil, err } } if err := w.Close(); err != nil { return nil, err } return buf.Bytes(), nil } // SendSMTP builds the MIME body and delivers it via the account's SMTP endpoint, // authenticating with SASL PLAIN. The envelope sender is m.From and the envelope // recipients are m.Recipients() (to + cc + bcc). func SendSMTP(cfg SMTPConfig, m OutgoingMessage) error { raw, err := BuildMIME(m) if err != nil { return err } addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) tlsConf := &tls.Config{ServerName: cfg.Host} var c *smtp.Client switch cfg.Security { case "tls": c, err = smtp.DialTLS(addr, tlsConf) case "starttls": c, err = smtp.DialStartTLS(addr, tlsConf) default: return fmt.Errorf("unknown smtp security %q", cfg.Security) } if err != nil { return fmt.Errorf("smtp connect: %w", err) } defer c.Close() auth := sasl.NewPlainClient("", cfg.Username, cfg.Password) if err := c.Auth(auth); err != nil { return fmt.Errorf("smtp auth: %w", err) } if err := c.SendMail(m.From, m.Recipients(), bytes.NewReader(raw)); err != nil { return fmt.Errorf("smtp send: %w", err) } return c.Quit() }