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>
265 lines
8.4 KiB
Go
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, "<>") + ">"
|
|
}
|