Files
email_agent/internal/mailops/smtp.go
Paperclip Builder 7ca888f0c6 Phase 5: reply drafts — template/LLM draft, edit, SMTP send (AUD-71)
Implements Phase 5 of AGENTS.md §11 (FR-8, FR-15):

- Message detail pane (GET /partials/message) with body, recommended
  action, reply identity, template picker, editable draft, and history.
- internal/reply: OmniRoute (OpenAI-compatible) chat client and a
  template-based draft generator. All non-JEV models go through
  OmniRoute (§10).
- internal/mailops: SMTP send over smtp.gmail.com:587 with STARTTLS and
  the app password, forcing IPv4 (§9); FR-15 reply identity (send as the
  arrived-at X-Pm-Original-To address, else "Sam Rolfe"); threading via
  In-Reply-To/References; -check-smtp safe verification; and a
  [Gmail]/Sent Mail thread check.
- Migration v5 stores messages.message_id_header and drafts.sent_at;
  mailfetch parses Message-ID.
- Send is user-triggered only: CSRF + UI confirm + confirm=yes guard,
  no auto-send, duplicate-send refused. A sent reply files the original
  under Replied-to/<address> and logs every step (FR-13).
- Tests for reply, mailops SMTP/identity/thread check, store drafts and
  migration v5, and the server draft/edit/send guards.
- docs/phase5-replies.md, README, .env.example, docker-compose updated.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-11 22:05:35 +11:00

265 lines
8.4 KiB
Go

// 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 <arrived-at@address>".
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, "<>") + ">"
}