// SMTP send path for Phase 5 replies (FR-8, FR-15). // // Gmail SMTP is reached over IPv4 only, exactly like the IMAP read path // (AGENTS.md §9): IPv6 to Gmail is broken from .13. The connection upgrades with // STARTTLS on port 587 and authenticates with the same app password as IMAP. // // Reply identity (FR-15): the From header is the address the mail arrived at // (X-Pm-Original-To). When that is unknown, it falls back to Sam Rolfe at the // Gmail account. The SMTP envelope sender is always the authenticated Gmail // account, which is what Gmail expects; the From header carries the alias. package mailops import ( "context" "crypto/rand" "crypto/tls" "encoding/hex" "fmt" "mime" "net" "net/mail" "net/smtp" "strings" "time" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" ) // ReplyFromName is the display name used when the arrived-at address is unknown // (AGENTS.md §13.1: "Otherwise send as Sam Rolfe"). const ReplyFromName = "Sam Rolfe" // OutgoingMessage is one reply to send. type OutgoingMessage struct { // To is the recipient (the original sender). To string // From is the From header value, normally "Sam Rolfe ". From string // Subject is the reply subject (already prefixed with Re: when appropriate). Subject string // InReplyTo and References carry the original Message-ID so Gmail threads // the reply. InReplyTo string References string // Body is the plain-text reply body. Body string } // Sender sends a reply. The live implementation is *Client; tests substitute a // fake. type Sender interface { Send(ctx context.Context, msg OutgoingMessage) error } // SMTPConfig is the connection detail for the Gmail SMTP submission endpoint. type SMTPConfig struct { Host string Port int User string Password string } // smtpDialFunc opens a TCP connection. It is a field so tests can assert the // IPv4-only network is used. type smtpDialFunc func(ctx context.Context, host string, port int, timeout time.Duration) (net.Conn, error) // dialSMTP4 forces IPv4, matching mailfetch.Dial on the read path. func dialSMTP4(ctx context.Context, host string, port int, timeout time.Duration) (net.Conn, error) { d := net.Dialer{Timeout: timeout} return d.DialContext(ctx, "tcp4", net.JoinHostPort(host, fmt.Sprintf("%d", port))) } // ReplyIdentity returns the From header for a reply (FR-15): the address the // mail arrived at, otherwise Sam Rolfe at the Gmail account. func ReplyIdentity(msg store.Message, gmailUser string) string { addr := strings.TrimSpace(msg.SourceAddress) if addr == "" { addr = strings.TrimSpace(gmailUser) } if addr == "" { return ReplyFromName } return (&mail.Address{Name: ReplyFromName, Address: addr}).String() } // ReplyTo returns the recipient of a reply: the original sender. func ReplyTo(msg store.Message) string { addr := strings.TrimSpace(msg.FromAddr) if addr == "" { return "" } if parsed, err := mail.ParseAddress(addr); err == nil { return parsed.Address } return addr } // ReplySubject prefixes the original subject with "Re: " once. func ReplySubject(subject string) string { s := strings.TrimSpace(subject) if s == "" { return "Re: (no subject)" } if strings.HasPrefix(strings.ToLower(s), "re:") { return s } return "Re: " + s } // CheckSMTP verifies the submission path without sending mail: it dials Gmail // over IPv4, upgrades with STARTTLS, and authenticates with the app password. // It stops before MAIL FROM, so no message is sent. It is the safe part of the // Phase 5 send-path verification. func (c *Client) CheckSMTP(ctx context.Context) error { if !c.cfg.GmailConfigured() { return ErrNotConfigured } host := c.cfg.SMTPHost port := c.cfg.SMTPPort conn, err := c.smtpDial(ctx, host, port, mailfetch.DefaultTimeout) if err != nil { return fmt.Errorf("dial smtp %s:%d (ipv4): %w", host, port, err) } client, err := smtp.NewClient(conn, host) if err != nil { _ = conn.Close() return fmt.Errorf("smtp handshake: %w", err) } defer func() { _ = client.Close() }() if ok, _ := client.Extension("STARTTLS"); ok { if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { return fmt.Errorf("smtp starttls: %w", err) } } if err := client.Auth(smtp.PlainAuth("", c.cfg.GmailUser, c.cfg.GmailAppPassword, host)); err != nil { return fmt.Errorf("smtp auth: %w", err) } c.logger.Info("smtp check ok", "host", host, "port", port, "user", c.cfg.GmailUser) return client.Quit() } // Send builds and sends one reply over IPv4 + STARTTLS + the app password. func (c *Client) Send(ctx context.Context, msg OutgoingMessage) error { if !c.cfg.GmailConfigured() { return ErrNotConfigured } if strings.TrimSpace(msg.To) == "" { return fmt.Errorf("reply has no recipient") } host := c.cfg.SMTPHost port := c.cfg.SMTPPort conn, err := c.smtpDial(ctx, host, port, mailfetch.DefaultTimeout) if err != nil { return fmt.Errorf("dial smtp %s:%d (ipv4): %w", host, port, err) } client, err := smtp.NewClient(conn, host) if err != nil { _ = conn.Close() return fmt.Errorf("smtp handshake: %w", err) } defer func() { _ = client.Close() }() if ok, _ := client.Extension("STARTTLS"); ok { if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil { return fmt.Errorf("smtp starttls: %w", err) } } if err := client.Auth(smtp.PlainAuth("", c.cfg.GmailUser, c.cfg.GmailAppPassword, host)); err != nil { return fmt.Errorf("smtp auth: %w", err) } // The envelope sender is the authenticated account; the From header carries // the arrived-at alias (FR-15). if err := client.Mail(c.cfg.GmailUser); err != nil { return fmt.Errorf("smtp mail from: %w", err) } if err := client.Rcpt(msg.To); err != nil { return fmt.Errorf("smtp rcpt to %s: %w", msg.To, err) } w, err := client.Data() if err != nil { return fmt.Errorf("smtp data: %w", err) } raw := BuildRFC822(msg, c.cfg.GmailUser, time.Now().UTC()) if _, err := w.Write(raw); err != nil { _ = w.Close() return fmt.Errorf("write reply: %w", err) } if err := w.Close(); err != nil { return fmt.Errorf("finish reply: %w", err) } if err := client.Quit(); err != nil { return fmt.Errorf("smtp quit: %w", err) } c.logger.Info("reply sent", "to", msg.To, "from", msg.From, "subject", msg.Subject) return nil } // BuildRFC822 renders the reply as an RFC822 message. The Message-ID is // generated and the headers are CRLF-terminated for SMTP. It is exported so the // wire format can be tested without a live server. func BuildRFC822(msg OutgoingMessage, gmailUser string, now time.Time) []byte { var b strings.Builder writeHeader(&b, "From", msg.From) writeHeader(&b, "To", msg.To) writeHeader(&b, "Subject", msg.Subject) writeHeader(&b, "Date", now.Format(time.RFC1123Z)) writeHeader(&b, "Message-ID", generateMessageID(now, gmailUser)) if v := strings.TrimSpace(msg.InReplyTo); v != "" { writeHeader(&b, "In-Reply-To", ensureAngle(v)) } if v := strings.TrimSpace(msg.References); v != "" { writeHeader(&b, "References", ensureAngle(v)) } writeHeader(&b, "MIME-Version", "1.0") writeHeader(&b, "Content-Type", "text/plain; charset=UTF-8") writeHeader(&b, "Content-Transfer-Encoding", "8bit") b.WriteString("\r\n") b.WriteString(strings.ReplaceAll(strings.ReplaceAll(msg.Body, "\r\n", "\n"), "\n", "\r\n")) if !strings.HasSuffix(b.String(), "\r\n") { b.WriteString("\r\n") } return []byte(b.String()) } // writeHeader encodes a header value when it contains non-ASCII text. func writeHeader(b *strings.Builder, key, value string) { value = strings.TrimSpace(value) for _, r := range value { if r > 127 { value = mime.QEncoding.Encode("UTF-8", value) break } } b.WriteString(key) b.WriteString(": ") b.WriteString(value) b.WriteString("\r\n") } // generateMessageID builds a unique Message-ID for an outgoing reply. func generateMessageID(now time.Time, user string) string { domain := "email-agent.local" if user != "" { if i := strings.LastIndex(user, "@"); i >= 0 && i < len(user)-1 { domain = user[i+1:] } } buf := make([]byte, 8) _, _ = rand.Read(buf) return fmt.Sprintf("<%d.%s@%s>", now.UnixNano(), hex.EncodeToString(buf), domain) } // ensureAngle wraps a Message-ID in angle brackets when it is not already. func ensureAngle(id string) string { id = strings.TrimSpace(id) if strings.HasPrefix(id, "<") && strings.HasSuffix(id, ">") { return id } return "<" + strings.Trim(id, "<>") + ">" }