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>
This commit is contained in:
Paperclip Builder
2026-10-11 22:05:35 +11:00
parent ae53374ad1
commit 7ca888f0c6
31 changed files with 2556 additions and 42 deletions

View File

@@ -29,6 +29,11 @@ const (
DefaultIMAPLookbackDays = 14
DefaultIMAPMaxMessages = 100
// SMTP defaults (AGENTS.md §9): Gmail submission on 587 with STARTTLS. The
// dialer forces IPv4, matching IMAP.
DefaultSMTPHost = "smtp.gmail.com"
DefaultSMTPPort = 587
// JEV (TypeSafe System One) defaults. The base URL is fixed by the TypeSafe
// docs (https://docs.typesafe.ai/api.md); JEV_BASE_URL overrides it.
DefaultJEVBaseURL = "https://api.typesafe.ai"
@@ -38,6 +43,12 @@ const (
// max messages bounds one run so a scheduled scan stays cheap.
DefaultClassifyConcurrency = 4
DefaultClassifyMaxMessages = 200
// OmniRoute defaults (AGENTS.md §8/§10). Reply drafts are the first caller.
// The gateway runs on .13, the host that runs this service; the other house
// apps use the same API endpoint. OMNIROUTE_BASE_URL overrides it.
DefaultOmniRouteBaseURL = "http://192.168.20.13:20129/v1"
DefaultOmniRouteModel = "auto/best-chat"
)
// Config is the fully resolved runtime configuration.
@@ -83,10 +94,13 @@ type Config struct {
// ClassifyMaxMessages caps how many messages one classify run processes.
ClassifyMaxMessages int
// OmniRouteBaseURL is the base URL of the OmniRoute gateway.
// OmniRouteBaseURL is the base URL of the OmniRoute gateway. All non-JEV
// models go through it (AGENTS.md §10).
OmniRouteBaseURL string
// OmniRouteAPIKey authenticates to OmniRoute. Secret.
OmniRouteAPIKey string
// OmniRouteModel is the model alias used for reply drafts.
OmniRouteModel string
// GmailUser is the Gmail account that receives the forwarded mail.
// Secret (half of the app-password pair).
@@ -102,6 +116,10 @@ type Config struct {
IMAPLookbackDays int
// IMAPMaxMessages caps how many messages one scan fetches.
IMAPMaxMessages int
// SMTPHost is the Gmail submission host (default smtp.gmail.com).
SMTPHost string
// SMTPPort is the Gmail submission port (default 587, STARTTLS).
SMTPPort int
// HubToken authenticates the read-only Hub API. Secret.
HubToken string
@@ -118,8 +136,9 @@ func Load() (*Config, error) {
JEVBaseURL: strings.TrimRight(getenvDefault("JEV_BASE_URL", DefaultJEVBaseURL), "/"),
JEVAPIKey: os.Getenv("JEV_API_KEY"),
JEVModel: getenvDefault("JEV_MODEL", DefaultJEVModel),
OmniRouteBaseURL: strings.TrimRight(os.Getenv("OMNIROUTE_BASE_URL"), "/"),
OmniRouteBaseURL: strings.TrimRight(getenvDefault("OMNIROUTE_BASE_URL", DefaultOmniRouteBaseURL), "/"),
OmniRouteAPIKey: os.Getenv("OMNIROUTE_API_KEY"),
OmniRouteModel: getenvDefault("OMNIROUTE_MODEL", DefaultOmniRouteModel),
GmailUser: os.Getenv("GMAIL_USER"),
GmailAppPassword: os.Getenv("GMAIL_APP_PASSWORD"),
HubToken: os.Getenv("HUB_TOKEN"),
@@ -131,6 +150,12 @@ func Load() (*Config, error) {
}
c.IMAPHost = getenvDefault("EMAIL_AGENT_IMAP_HOST", DefaultIMAPHost)
c.IMAPPort = imapPort
smtpPort, err := resolvePortSetting("EMAIL_AGENT_SMTP_PORT", DefaultSMTPPort)
if err != nil {
return nil, err
}
c.SMTPHost = getenvDefault("EMAIL_AGENT_SMTP_HOST", DefaultSMTPHost)
c.SMTPPort = smtpPort
c.IMAPLookbackDays = getenvDefaultInt("EMAIL_AGENT_IMAP_LOOKBACK_DAYS", DefaultIMAPLookbackDays)
c.IMAPMaxMessages = getenvDefaultInt("EMAIL_AGENT_IMAP_MAX_MESSAGES", DefaultIMAPMaxMessages)
c.ClassifyConcurrency = getenvDefaultInt("EMAIL_AGENT_CLASSIFY_CONCURRENCY", DefaultClassifyConcurrency)
@@ -152,13 +177,19 @@ func Load() (*Config, error) {
// resolveIMAPPort reads EMAIL_AGENT_IMAP_PORT, falling back to DefaultIMAPPort.
func resolveIMAPPort() (int, error) {
raw := strings.TrimSpace(os.Getenv("EMAIL_AGENT_IMAP_PORT"))
return resolvePortSetting("EMAIL_AGENT_IMAP_PORT", DefaultIMAPPort)
}
// resolvePortSetting reads a port-valued environment variable, falling back to
// def when unset.
func resolvePortSetting(key string, def int) (int, error) {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return DefaultIMAPPort, nil
return def, nil
}
p, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("EMAIL_AGENT_IMAP_PORT %q is not a number: %w", raw, err)
return 0, fmt.Errorf("%s %q is not a number: %w", key, raw, err)
}
return p, nil
}
@@ -229,6 +260,12 @@ func (c *Config) validate() error {
if c.IMAPPort < 1 || c.IMAPPort > 65535 {
return fmt.Errorf("EMAIL_AGENT_IMAP_PORT %d is out of range", c.IMAPPort)
}
if strings.TrimSpace(c.SMTPHost) == "" {
return fmt.Errorf("EMAIL_AGENT_SMTP_HOST must not be empty")
}
if c.SMTPPort < 1 || c.SMTPPort > 65535 {
return fmt.Errorf("EMAIL_AGENT_SMTP_PORT %d is out of range", c.SMTPPort)
}
return nil
}