6a99e5bb6e
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
4.8 KiB
Go
183 lines
4.8 KiB
Go
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
|
|
}
|
|
|
|
// envelopeFrom returns the bare address for the SMTP envelope sender, stripping
|
|
// any display name. A display-name From (e.g. "Name <addr>") is a valid header
|
|
// but an invalid envelope sender, so it must be reduced to the bare address.
|
|
// Unparseable input is passed through unchanged (preserves prior behaviour for
|
|
// plain addresses).
|
|
func envelopeFrom(from string) string {
|
|
if a, err := gomail.ParseAddress(from); err == nil {
|
|
return a.Address
|
|
}
|
|
return from
|
|
}
|
|
|
|
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(envelopeFrom(m.From), m.Recipients(), bytes.NewReader(raw)); err != nil {
|
|
return fmt.Errorf("smtp send: %w", err)
|
|
}
|
|
return c.Quit()
|
|
}
|