f42d1594ef
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.
159 lines
5.7 KiB
Go
159 lines
5.7 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/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]) + "…"
|
|
}
|