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:
@@ -31,6 +31,9 @@ const (
|
||||
LabelDelete = "Delete"
|
||||
LabelSaved = "Saved"
|
||||
repliedPrefix = "Replied-to/"
|
||||
// LabelSent is Gmail's IMAP name for the Sent mailbox. Phase 5 checks it to
|
||||
// confirm a reply joined the original thread.
|
||||
LabelSent = "[Gmail]/Sent Mail"
|
||||
)
|
||||
|
||||
// ErrNotConfigured means Gmail credentials are missing, so no mailbox action can
|
||||
@@ -46,25 +49,30 @@ type Conn interface {
|
||||
Move(uid uint32, dest string) error
|
||||
StoreFlags(uid uint32, op string, flag string) error
|
||||
SearchGmailID(gmailID string) ([]uint32, error)
|
||||
SearchGmailThreadID(threadID string) ([]uint32, error)
|
||||
Logout() error
|
||||
}
|
||||
|
||||
// dialFunc opens an IMAP connection. It is a field so tests can substitute one.
|
||||
type dialFunc func(ctx context.Context, host string, port int, timeout time.Duration) (Conn, error)
|
||||
|
||||
// Client performs triage actions against Gmail IMAP.
|
||||
// Client performs triage actions against Gmail IMAP and sends replies over
|
||||
// Gmail SMTP.
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
logger *slog.Logger
|
||||
dial dialFunc
|
||||
// smtpDial opens the SMTP connection (IPv4 only). It is a field so tests can
|
||||
// substitute one; the production value is dialSMTP4.
|
||||
smtpDial smtpDialFunc
|
||||
}
|
||||
|
||||
// New builds a Client using the real Gmail IMAP dialer.
|
||||
// New builds a Client using the real Gmail IMAP dialer and IPv4 SMTP dialer.
|
||||
func New(cfg *config.Config, logger *slog.Logger) *Client {
|
||||
if logger == nil {
|
||||
logger = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
return &Client{cfg: cfg, logger: logger, dial: defaultDial}
|
||||
return &Client{cfg: cfg, logger: logger, dial: defaultDial, smtpDial: dialSMTP4}
|
||||
}
|
||||
|
||||
// defaultDial dials Gmail over IPv4 (mailfetch.Dial forces tcp4).
|
||||
@@ -143,6 +151,29 @@ func (c *Client) Mark(ctx context.Context, msg store.Message, mark string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyThreadUpdated reports whether the Gmail thread now contains a message in
|
||||
// the Sent mailbox. Phase 5 calls it after a reply is sent so the DoD "the
|
||||
// thread updates" can be checked without touching the recipient.
|
||||
func (c *Client) VerifyThreadUpdated(ctx context.Context, threadID string) (bool, error) {
|
||||
if strings.TrimSpace(threadID) == "" {
|
||||
return false, fmt.Errorf("message has no Gmail thread id")
|
||||
}
|
||||
conn, err := c.connect(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = conn.Logout() }()
|
||||
|
||||
if _, err := conn.Select(LabelSent); err != nil {
|
||||
return false, fmt.Errorf("select %q: %w", LabelSent, err)
|
||||
}
|
||||
uids, err := conn.SearchGmailThreadID(threadID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("search thread %s in %q: %w", threadID, LabelSent, err)
|
||||
}
|
||||
return len(uids) > 0, nil
|
||||
}
|
||||
|
||||
// connect dials and logs in. Callers must Logout when done.
|
||||
func (c *Client) connect(ctx context.Context) (Conn, error) {
|
||||
if !c.cfg.GmailConfigured() {
|
||||
|
||||
Reference in New Issue
Block a user