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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user