Turn a mail into task candidates, and into nothing else (#246)
The extraction half. internal/email.Extractor asks the resident Qwen3-1.7B, under a GBNF grammar, what one message requires of him, and returns at most three short candidates with an optional date. Everything it can produce is a row in `tasks` with status "candidate", written through the intake seam #130 built for exactly this (Source "email:<mailbox>", Evidence = the subject line). No reminder, no fact, no note, no calendar event. That bound is the design: a reminder FIRES, so a 1.7B misreading "встреча была в четверг" as a future appointment would wake him up about it, whereas a wrong candidate is a line he dismisses in one click. A due date the model read out of the mail is stored on the candidate, where no scheduler reads it — the review page sorts by it. Relative wording ("до пятницы") is deliberately left in the text rather than resolved to a date the model would get wrong. The prompt is written against the two things a small model does here: it summarises when asked to extract, and it invents an obligation out of a polite closing line. Hence the demand for a verb phrase, and an explicit empty array — most mail contains no task, and a model with no way to say "nothing" says something. Wiring: core owns extraction because llama-server lives in core's process, so the reader hands messages over a new ipc.MethodIngestMail. It is a Server hook (like StepUp/UnlockFn), not a CoreAPI method — not a store operation, and no CoreAPI implementation should have to carry it. The hook stays nil without an `email` config block or without a llama-server phraser, so the method answers ErrUnknownMethod: off unless configured, twice over. There is no keyword fallback on purpose — "the subject became a task" is a mailbox rendered as a to-do list, not extraction. Privacy: junk is refused before the model is called, mail text is never search input, extraction errors carry byte counts rather than the reply, the stored evidence is a truncated subject, and the log line names the mailbox and the UID only.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// 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/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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) *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(llm.New(lp.BaseURL(), 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}
|
||||
}
|
||||
|
||||
// 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++
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
mi := newMailIntake(st, phr, cfg)
|
||||
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]) + "…"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// mailLLM — a canned extraction reply.
|
||||
type mailLLM struct {
|
||||
reply string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *mailLLM) Complete(_ context.Context, _ llm.Req) (string, error) {
|
||||
m.calls++
|
||||
return m.reply, nil
|
||||
}
|
||||
|
||||
func newTestIntake(t *testing.T, reply string) (*mailIntake, *store.Store, *mailLLM) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
fake := &mailLLM{reply: reply}
|
||||
return &mailIntake{
|
||||
st: st,
|
||||
ex: email.NewExtractor(fake, 0, nil),
|
||||
timeout: 5 * time.Second,
|
||||
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
|
||||
}, st, fake
|
||||
}
|
||||
|
||||
func ingestReq() ipc.IngestMailReq {
|
||||
return ipc.IngestMailReq{
|
||||
Mailbox: "INBOX", UID: 42,
|
||||
From: "billing@isp.example",
|
||||
Subject: "Счёт за интернет",
|
||||
Body: "Оплатите счёт до 5 августа.",
|
||||
}
|
||||
}
|
||||
|
||||
// The one property that matters: a mail-derived task is a candidate, attributed
|
||||
// to the mailbox, with the subject as reviewable evidence — and nothing else is
|
||||
// written.
|
||||
func TestIngestCapturesCandidates(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт за интернет","due":"2026-08-05"}]`)
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 1 || len(resp.TaskIDs) != 1 {
|
||||
t.Fatalf("resp = %+v, want one created task", resp)
|
||||
}
|
||||
tasks, err := st.ListTasks(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("got %d tasks, want 1", len(tasks))
|
||||
}
|
||||
got := tasks[0]
|
||||
if got.Status != store.TaskCandidate {
|
||||
t.Errorf("status = %q, want %q — mail may only produce candidates", got.Status, store.TaskCandidate)
|
||||
}
|
||||
if got.Source != "email:INBOX" {
|
||||
t.Errorf("source = %q, want email:INBOX", got.Source)
|
||||
}
|
||||
if got.Evidence != "Счёт за интернет" {
|
||||
t.Errorf("evidence = %q, want the subject line", got.Evidence)
|
||||
}
|
||||
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
||||
t.Errorf("due = %v, want 2026-08-05", got.Due)
|
||||
}
|
||||
// Nothing else may have been written: no reminder, no fact.
|
||||
rem, err := st.ListReminders(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rem) != 0 {
|
||||
t.Errorf("mail created %d reminders; a misread mail must never be able to fire", len(rem))
|
||||
}
|
||||
}
|
||||
|
||||
// Re-reading a mailbox must not grow the list — CaptureTask dedupes among live
|
||||
// rows, and the intake relies on exactly that.
|
||||
func TestIngestSameMailTwiceIsIdempotent(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт","due":""}]`)
|
||||
if _, err := mi.ingest(context.Background(), ingestReq()); err != nil {
|
||||
t.Fatalf("first ingest: %v", err)
|
||||
}
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("second ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 0 || len(resp.TaskIDs) != 1 {
|
||||
t.Errorf("resp = %+v, want the existing row and Created=0", resp)
|
||||
}
|
||||
tasks, _ := st.ListTasks(context.Background(), "")
|
||||
if len(tasks) != 1 {
|
||||
t.Errorf("got %d tasks after two reads, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestJunkSkipsTheModel(t *testing.T) {
|
||||
mi, st, fake := newTestIntake(t, `[{"text":"купить со скидкой","due":""}]`)
|
||||
req := ingestReq()
|
||||
req.Junk = true
|
||||
resp, err := mi.ingest(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if !resp.Skipped || resp.Created != 0 {
|
||||
t.Errorf("resp = %+v, want skipped", resp)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Errorf("model called %d times for junk, want 0", fake.calls)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||
t.Errorf("junk produced %d tasks, want 0", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestEmptyMessageSkipped(t *testing.T) {
|
||||
mi, _, fake := newTestIntake(t, "[]")
|
||||
resp, err := mi.ingest(context.Background(), ipc.IngestMailReq{Mailbox: "INBOX", UID: 1})
|
||||
if err != nil || !resp.Skipped {
|
||||
t.Fatalf("resp = %+v, err = %v; want skipped", resp, err)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Errorf("model called %d times for an empty message, want 0", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestNoTasksWritesNothing(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, "[]")
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 0 || len(resp.TaskIDs) != 0 || resp.Skipped {
|
||||
t.Errorf("resp = %+v, want nothing captured and not skipped", resp)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||
t.Errorf("got %d tasks, want 0", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestTruncatesEvidence(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, `[{"text":"дело","due":""}]`)
|
||||
req := ingestReq()
|
||||
req.Subject = strings.Repeat("щ", 400)
|
||||
if _, err := mi.ingest(context.Background(), req); err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
tasks, _ := st.ListTasks(context.Background(), "")
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("got %d tasks, want 1", len(tasks))
|
||||
}
|
||||
if n := len([]rune(tasks[0].Evidence)); n > evidenceMaxChars+1 {
|
||||
t.Errorf("evidence kept %d runes, want ≤ %d", n, evidenceMaxChars)
|
||||
}
|
||||
}
|
||||
|
||||
// Off unless configured: no email block ⇒ no intake, so the IPC method does not
|
||||
// exist at all.
|
||||
func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
if mi := newMailIntake(st, nil, &config.Config{}); mi != nil {
|
||||
t.Error("no email block must mean no mail intake")
|
||||
}
|
||||
// Configured but with a non-LLM phraser: still off — there is no fallback
|
||||
// extraction, by design.
|
||||
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}); mi != nil {
|
||||
t.Error("without a llama-server phraser there is nothing to extract with")
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,13 @@ func run(args []string) error {
|
||||
|
||||
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
|
||||
|
||||
// Mail ingestion (Vikunja #246): the hook stays nil unless an email block is
|
||||
// configured and there is a llama-server to extract with, in which case
|
||||
// ipc.MethodIngestMail reports ErrUnknownMethod.
|
||||
if !locked {
|
||||
wireMailIntake(srv, st, phr, cfg)
|
||||
}
|
||||
|
||||
// WrapKeyFn — wraps the env key with a passkey credential public key and
|
||||
// persists the wrapped blob. Only wired when the daemon has the key in
|
||||
// memory (env key mode). Called by mavweb after passkey enrollment.
|
||||
@@ -457,6 +464,7 @@ func run(args []string) error {
|
||||
}
|
||||
srv.SetAPI(newAPI)
|
||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||
wireMailIntake(srv, st, phr, cfg)
|
||||
|
||||
// Start voice server.
|
||||
if voiceW != nil {
|
||||
|
||||
Reference in New Issue
Block a user