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>
278 lines
8.3 KiB
Go
278 lines
8.3 KiB
Go
// Package mailops performs the triage actions from Phase 4 over Gmail IMAP
|
|
// (FR-6, FR-9).
|
|
//
|
|
// The app's folders map to Gmail labels:
|
|
//
|
|
// inbox -> INBOX
|
|
// delete -> Delete (recoverable; never a hard delete)
|
|
// saved -> Saved
|
|
// replied -> Replied-to/<address> (FR-9, replied-to by address)
|
|
//
|
|
// Every connection forces IPv4, matching the read path (AGENTS.md §9).
|
|
package mailops
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
|
|
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch"
|
|
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
|
)
|
|
|
|
// Label names for the app folders. "Delete" and "Saved" are ordinary Gmail
|
|
// labels created lazily on first use.
|
|
const (
|
|
LabelInbox = "INBOX"
|
|
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
|
|
// be attempted.
|
|
var ErrNotConfigured = errors.New("Gmail is not configured: set GMAIL_USER and GMAIL_APP_PASSWORD")
|
|
|
|
// Conn is the subset of the IMAP client the actions use. It lets the actions be
|
|
// tested without a live Gmail connection.
|
|
type Conn interface {
|
|
Login(user, pass string) error
|
|
Select(mailbox string) (uint32, error)
|
|
CreateMailbox(name string) error
|
|
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 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 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, smtpDial: dialSMTP4}
|
|
}
|
|
|
|
// defaultDial dials Gmail over IPv4 (mailfetch.Dial forces tcp4).
|
|
func defaultDial(ctx context.Context, host string, port int, timeout time.Duration) (Conn, error) {
|
|
return mailfetch.Dial(ctx, host, port, timeout)
|
|
}
|
|
|
|
// Move files a message under destFolder and returns the Gmail label it now lives
|
|
// in. It creates the destination label when needed. A move to the folder the
|
|
// message is already in is a no-op.
|
|
func (c *Client) Move(ctx context.Context, msg store.Message, destFolder string) (string, error) {
|
|
dest := MailboxForFolder(destFolder, msg.SourceAddress)
|
|
source := msg.Mailbox
|
|
if strings.TrimSpace(source) == "" {
|
|
source = LabelInbox
|
|
}
|
|
if strings.EqualFold(dest, source) {
|
|
return dest, nil
|
|
}
|
|
|
|
conn, err := c.connect(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = conn.Logout() }()
|
|
|
|
if !strings.EqualFold(dest, LabelInbox) {
|
|
if err := conn.CreateMailbox(dest); err != nil {
|
|
return "", fmt.Errorf("create label %q: %w", dest, err)
|
|
}
|
|
}
|
|
uidValidity, err := conn.Select(source)
|
|
if err != nil {
|
|
return "", fmt.Errorf("select %q: %w", source, err)
|
|
}
|
|
uid, err := locate(conn, msg, source, uidValidity)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := conn.Move(uid, dest); err != nil {
|
|
return "", fmt.Errorf("move message %s from %q to %q: %w", msg.GmailID, source, dest, err)
|
|
}
|
|
c.logger.Info("message moved",
|
|
"message_id", msg.ID, "gmail_id", msg.GmailID, "from", source, "to", dest)
|
|
return dest, nil
|
|
}
|
|
|
|
// Mark applies a read/star flag change to a message in its current mailbox.
|
|
// mark is one of read, unread, star, unstar.
|
|
func (c *Client) Mark(ctx context.Context, msg store.Message, mark string) error {
|
|
op, flag, err := markFlag(mark)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
source := msg.Mailbox
|
|
if strings.TrimSpace(source) == "" {
|
|
source = LabelInbox
|
|
}
|
|
conn, err := c.connect(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = conn.Logout() }()
|
|
|
|
uidValidity, err := conn.Select(source)
|
|
if err != nil {
|
|
return fmt.Errorf("select %q: %w", source, err)
|
|
}
|
|
uid, err := locate(conn, msg, source, uidValidity)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := conn.StoreFlags(uid, op, flag); err != nil {
|
|
return fmt.Errorf("mark message %s %s: %w", msg.GmailID, mark, err)
|
|
}
|
|
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() {
|
|
return nil, ErrNotConfigured
|
|
}
|
|
conn, err := c.dial(ctx, c.cfg.IMAPHost, c.cfg.IMAPPort, mailfetch.DefaultTimeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := conn.Login(c.cfg.GmailUser, c.cfg.GmailAppPassword); err != nil {
|
|
_ = conn.Logout()
|
|
return nil, fmt.Errorf("imap login: %w", err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
// locate finds a message's UID in the selected mailbox. Gmail gives each label
|
|
// its own UID, so the stable X-GM-MSGID is always used rather than a UID stored
|
|
// from a previous mailbox.
|
|
func locate(conn Conn, msg store.Message, source string, uidValidity uint32) (uint32, error) {
|
|
if !isGmailMessageID(msg.GmailID) {
|
|
return 0, fmt.Errorf("message %d has no Gmail message id; cannot locate it in %q", msg.ID, source)
|
|
}
|
|
uids, err := conn.SearchGmailID(msg.GmailID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("search %q for %s: %w", source, msg.GmailID, err)
|
|
}
|
|
if len(uids) == 0 {
|
|
return 0, fmt.Errorf("message %s is not in %q", msg.GmailID, source)
|
|
}
|
|
_ = uidValidity
|
|
return uids[len(uids)-1], nil
|
|
}
|
|
|
|
// isGmailMessageID reports whether id is a numeric X-GM-MSGID. Messages stored
|
|
// with the "uid:<validity>:<uid>" fallback cannot be searched across labels.
|
|
func isGmailMessageID(id string) bool {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return false
|
|
}
|
|
for _, r := range id {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MailboxForFolder maps an app folder to a Gmail label. The replied folder uses
|
|
// the address the mail arrived at (FR-15), sanitised into a legal label.
|
|
func MailboxForFolder(folder, sourceAddress string) string {
|
|
switch folder {
|
|
case store.FolderDelete:
|
|
return LabelDelete
|
|
case store.FolderSaved:
|
|
return LabelSaved
|
|
case store.FolderReplied:
|
|
return repliedPrefix + SanitizeLabelAddress(sourceAddress)
|
|
default:
|
|
return LabelInbox
|
|
}
|
|
}
|
|
|
|
// SanitizeLabelAddress turns an email address into a Gmail-label-safe segment.
|
|
func SanitizeLabelAddress(addr string) string {
|
|
addr = strings.TrimSpace(addr)
|
|
addr = strings.Trim(addr, "<>")
|
|
addr = strings.ToLower(addr)
|
|
var b strings.Builder
|
|
for _, r := range addr {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9',
|
|
r == '@', r == '.', r == '_', r == '+', r == '-':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteRune('_')
|
|
}
|
|
}
|
|
out := b.String()
|
|
if out == "" {
|
|
return "unknown"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// markFlag maps an action name to the IMAP flag operation.
|
|
func markFlag(mark string) (op, flag string, err error) {
|
|
switch strings.ToLower(strings.TrimSpace(mark)) {
|
|
case "read":
|
|
return "+", "\\Seen", nil
|
|
case "unread":
|
|
return "-", "\\Seen", nil
|
|
case "star":
|
|
return "+", "\\Flagged", nil
|
|
case "unstar":
|
|
return "-", "\\Flagged", nil
|
|
default:
|
|
return "", "", fmt.Errorf("unknown mark %q", mark)
|
|
}
|
|
}
|