45b5e16eff
Things arrive at Maven from eight directions — a relayed Android notification on POST /api/ambient, mail candidates from mavmaild, RSS items, changed pages from the crawler, zenmoney and wg reads from mavpoll, CalDAV events, presence probes, meeting transcripts and image descriptions. Each grew its own shape and its own log line, and nothing could answer "what came in today, from where". internal/event is that answer: a flat source-agnostic envelope (Source, Kind, EntityIDs, Title, Body, Priority, OccurredAt, Payload) plus a bounded in-memory journal. Both are pure — Publish and Normalize take `now` as a parameter, so no clock read sits on a path a replay would drive. Adopting it did not touch eight callers, because every intake path already converges on three ipc.CoreAPI methods: WriteFact, WriteNote and CaptureTask. cmd/mavend/intake.go decorates that ONE interface, so mavweb, mavcaldav, mavpoll, mavmaild and the in-core feed/crawl/capture/ vision workers publish envelopes without knowing events exist. The lone exception is cmd/mavend/mail.go, which captures through the store directly and now publishes explicitly. Nothing dispatches on an event. It is a report that something arrived, never an instruction to speak — "a feed item appeared" becoming a notification is the nag this repo refuses. Digestion may read the journal later; it will still go through internal/loop's rules and the severity/presence routing table. Read surface: ipc.MethodRecentEvents (AuthRead, daemon-cached like TickTrace — a bare store cannot serve a ring) and a read-only /events page in mavweb. Production is unchanged when nobody is watching: a nil *event.Bus makes Publish a no-op and newIntakeAPI returns the wrapped API untouched, so config.intake_journal < 0 leaves no decorator on the call path at all. The default is 512 entries; the "off unless configured" rule is for capabilities that reach out, and a bounded in-memory log of writes core already performed reaches nowhere. Verified: make build, make test (go test -race) both clean. New tests cover the envelope and ring (internal/event, 95.7%), the decorator's invariants — a failed write publishes nothing, a deduped capture publishes nothing, OccurredAt is the fact's Ts and not notice time — and the /events page including escaping of feed-supplied titles.
168 lines
6.2 KiB
Go
168 lines
6.2 KiB
Go
// 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:<mailbox>"
|
|
// 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]) + "…"
|
|
}
|