// mavend/mail.go — core's half of the email reader (Vikunja #246, // docs/plans/01-email-reader.md). // // The split: cmd/mavmaild holds the IMAP credential, connects to the mailbox // and converts messages to plaintext; it hands each message to core over // ipc.MethodIngestMail. Core runs the extraction on the resident model — // llama-server lives in this process, spawned by the phraser — and writes what // comes back through the one task intake seam. // // What this file may produce is exactly one thing: rows in `tasks` with status // "candidate". No fact, no reminder, no note, no nudge, no calendar event. A // 1.7B misreading a mail can therefore put a wrong line on a review page and // nothing else; it can never make Maven speak, and it can never make her // recite something out of an advert as true. // // Off unless configured twice over: no `email` block in mavend.json ⇒ the IPC // method does not exist; no llama-server phraser ⇒ same. A reader pointed at a // core that is not set up for mail gets ErrUnknownMethod rather than silence. package main import ( "context" "fmt" "log" "time" "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/email" "github.com/kami/maven/internal/event" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/store" ) // evidenceMaxChars — how much of the subject line is kept as a candidate's // evidence. Enough to recognise the mail on /tasks, not enough to turn the task // list into a copy of his mailbox. const evidenceMaxChars = 160 // mailIntake — extraction + capture for one message at a time. type mailIntake struct { st *store.Store ex *email.Extractor timeout time.Duration now func() time.Time // bus — the unified intake journal (Vikunja #283). This path captures // through the store directly rather than through ipc.CoreAPI, so the // decorator in intake.go does not see it and the publish is explicit here. // nil is a working no-op. bus *event.Bus } // newMailIntake returns nil when mail ingestion must not be available, which is // the default. Both preconditions are real: // // - no cfg.Email ⇒ not configured, and a capability is off unless configured; // - no llama-server phraser ⇒ nothing to extract with. There is deliberately // no keyword fallback: "the subject line became a task" is not extraction, // it is a mailbox rendered as a to-do list, and it would fill the review // page faster than he could clear it. func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) *mailIntake { if cfg.Email == nil { return nil } lp, ok := phr.(*phraser.LLMPhraser) if !ok { log.Printf("mail intake: configured but no llama-server phraser — mail ingestion disabled") return nil } timeout := time.Duration(cfg.Email.Timeout) if timeout <= 0 { timeout = config.DefaultEmailTimeout } ex := email.NewExtractor(llmClientFor(lp, timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now)) log.Printf("mail intake: enabled (max %d candidates per message, timeout %s)", cfg.Email.MaxTasks, timeout) return &mailIntake{st: st, ex: ex, timeout: timeout, now: time.Now, bus: bus} } // ingest handles one ipc.MethodIngestMail call. // // Junk and empty messages are answered Skipped without touching the model — the // reader's header filter is what keeps the resident model off newsletters. // // Every candidate is captured with Status "candidate", Source "email:" // and the subject as Evidence. CaptureTask dedupes on normalised text among // live rows, so a mailbox re-read after a restart produces Created=0 rather // than a second copy of every task. func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) { msg := email.Message{ UID: req.UID, From: req.From, Subject: req.Subject, Date: req.Date, Body: req.Body, Junk: req.Junk, } if msg.Junk || (msg.Subject == "" && msg.Body == "") { return ipc.IngestMailResp{Skipped: true}, nil } ctx, cancel := context.WithTimeout(ctx, m.timeout) defer cancel() cands, err := m.ex.Extract(ctx, msg) if err != nil { // The error from internal/email never carries mail text; keep it that way // by not adding the subject here. return ipc.IngestMailResp{}, fmt.Errorf("mail intake: uid %d: %w", req.UID, err) } if len(cands) == 0 { return ipc.IngestMailResp{}, nil } source := email.SourcePrefix + req.Mailbox evidence := truncateRunes(req.Subject, evidenceMaxChars) now := m.now() var resp ipc.IngestMailResp for _, c := range cands { t := store.Task{ CreatedTs: now, Text: c.Text, Source: source, Evidence: evidence, // The one status this path may ever write. Anything Maven derived from // something she read is a suggestion until he confirms it on /tasks. Status: store.TaskCandidate, } if due, ok := email.ParseDue(c.Due); ok { t.Due = &due } id, created, err := m.st.CaptureTask(ctx, t) if err != nil { return resp, fmt.Errorf("mail intake: capture: %w", err) } resp.TaskIDs = append(resp.TaskIDs, id) if created { resp.Created++ // Only a row that was actually created. CaptureTask dedupes on // normalised text among live rows, so a mailbox re-read after a // restart must not refill the journal with tasks already in it. m.bus.Publish(publishableTask(t, now), now) } } // Counts only: the log line names the mailbox and the UID, never the subject, // the sender or the task text. Reviewing a candidate is what /tasks is for. log.Printf("mail intake: %s uid %d → %d candidate(s), %d new", source, req.UID, len(cands), resp.Created) return resp, nil } // wireMailIntake installs the IPC hook, or leaves it nil so the method reports // ErrUnknownMethod. Called on both startup paths (unlocked boot and passkey // unlock) so mail behaves the same either way. func wireMailIntake(srv *ipc.Server, st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) { mi := newMailIntake(st, phr, cfg, bus) if mi == nil { return } srv.IngestMailFn = mi.ingest } // truncateRunes cuts a string to n runes, marking the cut. func truncateRunes(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + "…" }