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>
162 lines
4.2 KiB
Go
162 lines
4.2 KiB
Go
package mailfetch
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
|
|
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
|
)
|
|
|
|
// ErrNoCredentials means IMAP login cannot be attempted.
|
|
var ErrNoCredentials = errors.New("GMAIL_USER and GMAIL_APP_PASSWORD are not configured")
|
|
|
|
// ErrAlreadyRunning means a scan is already in progress.
|
|
var ErrAlreadyRunning = errors.New("a mail scan is already running")
|
|
|
|
// Fetcher performs an IMAP scan and stores the results.
|
|
type Fetcher struct {
|
|
cfg *config.Config
|
|
store *store.Store
|
|
logger *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewFetcher builds a Fetcher.
|
|
func NewFetcher(cfg *config.Config, st *store.Store, logger *slog.Logger) *Fetcher {
|
|
return &Fetcher{cfg: cfg, store: st, logger: logger}
|
|
}
|
|
|
|
// Summary describes the outcome of one scan.
|
|
type Summary struct {
|
|
UIDValidity uint32
|
|
Since time.Time
|
|
Fetched int
|
|
Stored int
|
|
Duration time.Duration
|
|
}
|
|
|
|
// Fetch performs one bounded IMAP scan of INBOX and upserts the results.
|
|
func (f *Fetcher) Fetch(ctx context.Context) (Summary, error) {
|
|
if !f.cfg.GmailConfigured() {
|
|
return Summary{}, ErrNoCredentials
|
|
}
|
|
if !f.mu.TryLock() {
|
|
return Summary{}, ErrAlreadyRunning
|
|
}
|
|
defer f.mu.Unlock()
|
|
|
|
start := time.Now()
|
|
client, err := Dial(ctx, f.cfg.IMAPHost, f.cfg.IMAPPort, DefaultTimeout)
|
|
if err != nil {
|
|
return Summary{}, err
|
|
}
|
|
defer func() { _ = client.Logout() }()
|
|
|
|
if err := client.Login(f.cfg.GmailUser, f.cfg.GmailAppPassword); err != nil {
|
|
return Summary{}, err
|
|
}
|
|
|
|
uidValidity, err := client.Select("INBOX")
|
|
if err != nil {
|
|
return Summary{}, err
|
|
}
|
|
|
|
since := time.Now().UTC().AddDate(0, 0, -f.cfg.IMAPLookbackDays)
|
|
uids, err := client.SearchSince(since)
|
|
if err != nil {
|
|
return Summary{}, err
|
|
}
|
|
if max := f.cfg.IMAPMaxMessages; max > 0 && len(uids) > max {
|
|
// Keep the newest; SEARCH returns ascending UIDs.
|
|
uids = uids[len(uids)-max:]
|
|
}
|
|
|
|
results, err := client.Fetch(uids)
|
|
if err != nil {
|
|
return Summary{}, err
|
|
}
|
|
|
|
sum := Summary{
|
|
UIDValidity: uidValidity,
|
|
Since: since,
|
|
Fetched: len(results),
|
|
}
|
|
now := time.Now().UTC()
|
|
headersSeen := map[string]struct{}{}
|
|
for _, r := range results {
|
|
parsed, perr := ParseMessage(r.Raw)
|
|
if perr != nil {
|
|
f.logger.Warn("skip unparsable message", "uid", r.UID, "error", perr)
|
|
continue
|
|
}
|
|
if parsed.SourceHeader != "" {
|
|
headersSeen[parsed.SourceHeader] = struct{}{}
|
|
}
|
|
msg := &store.Message{
|
|
GmailID: gmailID(r, uidValidity),
|
|
ThreadID: r.GmailThreadID,
|
|
MessageIDHeader: parsed.MessageID,
|
|
FromAddr: parsed.From,
|
|
ToAddr: parsed.To,
|
|
Subject: parsed.Subject,
|
|
BodyText: parsed.BodyText,
|
|
SourceAddress: parsed.SourceAddress,
|
|
DateSent: parsed.Date,
|
|
ReceivedAt: now,
|
|
IMAPUID: int64(r.UID),
|
|
IMAPUIDValidity: int64(uidValidity),
|
|
}
|
|
if _, err := f.store.UpsertMessage(ctx, msg); err != nil {
|
|
return sum, err
|
|
}
|
|
sum.Stored++
|
|
}
|
|
sum.Duration = time.Since(start)
|
|
|
|
if err := f.store.LogAction(ctx, "fetch", "agent",
|
|
fmt.Sprintf("uidvalidity=%d since=%s fetched=%d stored=%d source_headers=%s",
|
|
uidValidity, since.Format("2006-01-02"), sum.Fetched, sum.Stored, joinKeys(headersSeen))); err != nil {
|
|
f.logger.Warn("record fetch action", "error", err)
|
|
}
|
|
|
|
f.logger.Info("mail scan complete",
|
|
"uidvalidity", uidValidity,
|
|
"since", since.Format("2006-01-02"),
|
|
"fetched", sum.Fetched,
|
|
"stored", sum.Stored,
|
|
"duration_ms", sum.Duration.Milliseconds(),
|
|
)
|
|
return sum, nil
|
|
}
|
|
|
|
// gmailID prefers Gmail's stable X-GM-MSGID and falls back to a UID key that is
|
|
// scoped to the UIDVALIDITY it was issued under.
|
|
func gmailID(r FetchResult, uidValidity uint32) string {
|
|
if r.GmailMsgID != "" {
|
|
return r.GmailMsgID
|
|
}
|
|
return "uid:" + strconv.FormatUint(uint64(uidValidity), 10) + ":" + strconv.FormatUint(uint64(r.UID), 10)
|
|
}
|
|
|
|
// joinKeys returns the sorted keys of a string set, for a log line.
|
|
func joinKeys(set map[string]struct{}) string {
|
|
if len(set) == 0 {
|
|
return "none"
|
|
}
|
|
parts := make([]string, 0, len(set))
|
|
for k := range set {
|
|
parts = append(parts, k)
|
|
}
|
|
slices.Sort(parts)
|
|
return strings.Join(parts, ",")
|
|
}
|