Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f42d1594ef | |||
| b4646155b4 |
@@ -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 {
|
||||
|
||||
@@ -74,7 +74,12 @@ func Requirement(m ipc.Method) Authority {
|
||||
// existing analogue.
|
||||
ipc.MethodCaptureTask,
|
||||
ipc.MethodListTasks,
|
||||
ipc.MethodSetTaskStatus:
|
||||
ipc.MethodSetTaskStatus,
|
||||
// Mail ingestion (Vikunja #246). AuthRead because of what the method can
|
||||
// produce: candidate tasks and nothing else. It cannot write a fact, set a
|
||||
// reminder, or touch the tool allowlist, so a compromised mail reader can
|
||||
// at worst put junk on a review page he clears in one click.
|
||||
ipc.MethodIngestMail:
|
||||
return AuthRead
|
||||
}
|
||||
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
||||
|
||||
@@ -150,6 +150,12 @@ type Config struct {
|
||||
// absent ⇒ no evaluation loop at all. See MemoryEvalConfig.
|
||||
MemoryEval *MemoryEvalConfig `json:"memory_eval,omitempty"`
|
||||
|
||||
// Email — mail ingestion (Vikunja #246). nil / absent ⇒ core refuses
|
||||
// ipc.MethodIngestMail outright, so a mail reader cannot make Maven read a
|
||||
// mailbox by merely existing. See EmailConfig; the IMAP host and credential
|
||||
// live in the reader (cmd/mavmaild), never here.
|
||||
Email *EmailConfig `json:"email,omitempty"`
|
||||
|
||||
// Praxis — the ecosystem attention-state service. When configured, maven
|
||||
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
||||
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
||||
@@ -418,6 +424,26 @@ type MemoryEvalConfig struct {
|
||||
MinConfidence float64 `json:"min_confidence,omitempty"`
|
||||
}
|
||||
|
||||
// EmailConfig — core's half of the email reader: how many task candidates one
|
||||
// message may produce, and how long the extraction call may take.
|
||||
//
|
||||
// There is deliberately nothing about a mailbox here. Core does not connect to
|
||||
// IMAP, does not know an account exists, and holds no mail credential — the
|
||||
// reader daemon does, the same split mavpoll uses for the zenmoney token. This
|
||||
// block only says "extraction is allowed, with these bounds".
|
||||
type EmailConfig struct {
|
||||
// MaxTasks — candidates per message. 0 ⇒ email.MaxCandidates (3).
|
||||
MaxTasks int `json:"max_tasks,omitempty"`
|
||||
|
||||
// Timeout — per-message extraction budget. 0 ⇒ DefaultEmailTimeout. This is
|
||||
// a Thinking model reading a mail; nobody is waiting on the answer, but a
|
||||
// hung llama-server must not pin the reader's connection forever.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultEmailTimeout — extraction budget per message.
|
||||
const DefaultEmailTimeout = 2 * time.Minute
|
||||
|
||||
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
||||
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
||||
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
||||
@@ -607,6 +633,12 @@ func (c *Config) applyDefaults() {
|
||||
c.MemoryEval.Interval = Duration(DefaultMemoryEvalInterval)
|
||||
}
|
||||
|
||||
// Same rule again: absent stays nil (⇒ mail ingestion refused), present gets
|
||||
// the timeout default so `{}` is a valid "on with the defaults".
|
||||
if c.Email != nil && c.Email.Timeout <= 0 {
|
||||
c.Email.Timeout = Duration(DefaultEmailTimeout)
|
||||
}
|
||||
|
||||
if c.Voice != nil {
|
||||
if c.Voice.RouterThreshold <= 0 {
|
||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/persona"
|
||||
)
|
||||
|
||||
// Extraction — turning one mail into task CANDIDATES, and nothing else.
|
||||
//
|
||||
// The output of this file can only ever become rows in `tasks` with status
|
||||
// "candidate" (store.TaskCandidate), written through the one intake seam
|
||||
// (ipc.CaptureTaskReq, Vikunja #130). That bound is the whole design:
|
||||
//
|
||||
// - No reminder. A reminder FIRES; it speaks to him unprompted. A 1.7B that
|
||||
// misreads "встреча была в четверг" as a future appointment would then wake
|
||||
// him up about it. A candidate that is wrong is a line on a review page he
|
||||
// dismisses in one click, which is the correct cost of a model being wrong
|
||||
// about someone's mail.
|
||||
// - No fact. A fact is a claim Maven will later recite as true. Nothing read
|
||||
// out of a marketing mail deserves that standing.
|
||||
// - No calendar event, no note, no action. Extraction writes candidates or
|
||||
// writes nothing.
|
||||
//
|
||||
// The due date the model may return is stored on the candidate (tasks.due_ts),
|
||||
// which no scheduler reads — it is there so the review page can sort by it.
|
||||
//
|
||||
// Privacy: the mail text goes to the resident model on this box and nowhere
|
||||
// else. It is never search input (CLAUDE.md: "his notes and facts are never
|
||||
// search input" — mail is the same class), and Evidence keeps only the subject
|
||||
// line, so the review page shows him where a candidate came from without the
|
||||
// store growing a copy of his mailbox.
|
||||
|
||||
// MaxCandidates — at most this many candidates per message, enforced by the
|
||||
// grammar. A mail with four tasks in it is a mail he has to read himself; a
|
||||
// model allowed ten will produce ten.
|
||||
const MaxCandidates = 3
|
||||
|
||||
// SourcePrefix — provenance for everything this package captures. The mailbox
|
||||
// name is appended: "email:INBOX". Same vocabulary as tap:voice / poll:netdata.
|
||||
const SourcePrefix = "email:"
|
||||
|
||||
// Candidate — one piece of work the model thinks the mail is asking for.
|
||||
type Candidate struct {
|
||||
Text string `json:"text"`
|
||||
// Due — "YYYY-MM-DD" or empty. A date the model read out of the text, not a
|
||||
// date it computed: relative wording ("до пятницы") is left in Text, because
|
||||
// a small model resolving "пятница" against today's date gets it wrong often
|
||||
// enough that a stored wrong date is worse than no date.
|
||||
Due string `json:"due"`
|
||||
}
|
||||
|
||||
// Completer — the llama-server seam, same shape memeval and the router use, so
|
||||
// the one resident model serves this caller too.
|
||||
type Completer interface {
|
||||
Complete(ctx context.Context, r llm.Req) (string, error)
|
||||
}
|
||||
|
||||
// Extractor reads a message and returns candidates. It holds no store and no
|
||||
// writer on purpose: this type cannot persist anything, so "extraction never
|
||||
// acts" is a property of the code, not of a review.
|
||||
type Extractor struct {
|
||||
llm Completer
|
||||
// MaxCandidates — 0 ⇒ MaxCandidates.
|
||||
max int
|
||||
// ContextBlock — the shared persona block, optional. Extraction output is
|
||||
// not spoken, so the persona matters less here than in the phraser; it is
|
||||
// wired anyway so a candidate reads in her voice on the review page.
|
||||
contextBlock func() string
|
||||
}
|
||||
|
||||
func NewExtractor(c Completer, max int, contextBlock func() string) *Extractor {
|
||||
if max <= 0 || max > MaxCandidates {
|
||||
max = MaxCandidates
|
||||
}
|
||||
return &Extractor{llm: c, max: max, contextBlock: contextBlock}
|
||||
}
|
||||
|
||||
// extractGrammar — GBNF pinning the answer to a bounded array of fixed-shape
|
||||
// candidates. Same reasoning as memeval's evalGrammar and the router's
|
||||
// routeGrammar: the shape and the length bound are what keep a small model from
|
||||
// drifting into prose or spending the token budget repeating one field.
|
||||
//
|
||||
// The empty array is reachable, deliberately: most mail contains no task, and a
|
||||
// model with no way to say "nothing" invents something.
|
||||
const extractGrammar = `
|
||||
root ::= "[" ws (item ("," ws item){0,2})? ws "]"
|
||||
item ::= "{" ws "\"text\"" ws ":" ws text "," ws "\"due\"" ws ":" ws due ws "}"
|
||||
text ::= "\"" ([^"\\] | "\\" .){1,120} "\""
|
||||
due ::= "\"\"" | "\"" [0-9]{4} "-" [0-9]{2} "-" [0-9]{2} "\""
|
||||
ws ::= [ \t\n]*
|
||||
`
|
||||
|
||||
// extractSystem — the extraction prompt.
|
||||
//
|
||||
// Written around the two failure modes a small model has on this task: it
|
||||
// summarises when asked to extract (turning a mail into "письмо от Антона"),
|
||||
// and it invents an obligation from any polite closing sentence. Hence the
|
||||
// insistence on a verb phrase, and the explicit permission to return [].
|
||||
const extractSystem = `Ты читаешь одно письмо из его почты и достаёшь из него дела, которые письмо от него требует.
|
||||
|
||||
Правила:
|
||||
- Отвечай ТОЛЬКО массивом JSON. Каждый элемент: {"text": "...", "due": "ГГГГ-ММ-ДД" или ""}.
|
||||
- text — короткая формулировка дела по-русски, с глаголом: "оплатить счёт за интернет", "отправить акт". Не пересказывай письмо и не описывай его.
|
||||
- Дело — это то, что должен сделать ОН. Рассылка, реклама, уведомление, отчёт, письмо «просто к сведению» — дел не содержат.
|
||||
- Если письмо ничего от него не требует, верни пустой массив []. Это нормальный ответ, так бывает чаще всего.
|
||||
- Ничего не придумывай. Если срока в письме нет — "".
|
||||
- due заполняй только когда в письме стоит конкретная дата. Слова вроде «до пятницы» оставь в text, дату не вычисляй.
|
||||
- Максимум три дела. Лучше одно точное, чем три общих.`
|
||||
|
||||
// Extract returns the candidates in one message.
|
||||
//
|
||||
// Junk is refused without an LLM call — cheapest possible defence, and the
|
||||
// reason the header filter exists. An empty message (no subject, no body) is
|
||||
// likewise not worth a round trip.
|
||||
//
|
||||
// A parse failure is an error the caller logs and moves past. It is never
|
||||
// silently turned into zero candidates, because "the model went off the rails"
|
||||
// and "the mail contains no task" want different reactions from a human reading
|
||||
// the log.
|
||||
func (e *Extractor) Extract(ctx context.Context, msg Message) ([]Candidate, error) {
|
||||
if msg.Junk {
|
||||
return nil, nil
|
||||
}
|
||||
user := renderForModel(msg)
|
||||
if user == "" {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := e.llm.Complete(ctx, llm.Req{
|
||||
System: persona.Prepend(e.contextBlock, extractSystem),
|
||||
User: user,
|
||||
Grammar: extractGrammar,
|
||||
MaxTokens: 512,
|
||||
RepeatPenalty: 1.1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: extract: %w", err)
|
||||
}
|
||||
items, err := parseCandidates(raw)
|
||||
if err != nil {
|
||||
// The raw reply is NOT in the error: it is a transformation of his mail,
|
||||
// and this error reaches the daemon log.
|
||||
return nil, fmt.Errorf("email: extract: unparsable reply (%d bytes)", len(raw))
|
||||
}
|
||||
out := make([]Candidate, 0, len(items))
|
||||
seen := map[string]bool{}
|
||||
for _, it := range items {
|
||||
it.Text = strings.TrimSpace(it.Text)
|
||||
if it.Text == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.Join(strings.Fields(it.Text), " "))
|
||||
if seen[key] {
|
||||
continue // the model repeating itself is not two tasks
|
||||
}
|
||||
seen[key] = true
|
||||
if _, ok := ParseDue(it.Due); !ok {
|
||||
it.Due = "" // a date the grammar allowed but the calendar does not
|
||||
}
|
||||
out = append(out, it)
|
||||
if len(out) >= e.max {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// renderForModel is the user turn: subject, sender and body, labelled. Only
|
||||
// these three fields — no headers, no recipient list, no message-id, nothing
|
||||
// that would let the model start reasoning about routing metadata.
|
||||
func renderForModel(msg Message) string {
|
||||
var b strings.Builder
|
||||
if msg.From != "" {
|
||||
fmt.Fprintf(&b, "От: %s\n", msg.From)
|
||||
}
|
||||
if msg.Subject != "" {
|
||||
fmt.Fprintf(&b, "Тема: %s\n", msg.Subject)
|
||||
}
|
||||
if msg.Body != "" {
|
||||
fmt.Fprintf(&b, "\n%s\n", msg.Body)
|
||||
}
|
||||
if msg.Subject == "" && msg.Body == "" {
|
||||
return ""
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseCandidates decodes the grammar-constrained reply, tolerating the
|
||||
// wrappers a Thinking model sometimes leaves around it (a fenced block, or
|
||||
// leading reasoning before the array).
|
||||
func parseCandidates(raw string) ([]Candidate, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if i := strings.Index(s, "["); i > 0 {
|
||||
s = s[i:]
|
||||
}
|
||||
if j := strings.LastIndex(s, "]"); j >= 0 {
|
||||
s = s[:j+1]
|
||||
}
|
||||
var out []Candidate
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ParseDue turns the model's "YYYY-MM-DD" into a time in UTC. Exported because
|
||||
// the daemon-side intake stores it on the candidate.
|
||||
//
|
||||
// The zero-value/empty case returns ok=false rather than an error: no date is
|
||||
// the common answer, not a failure.
|
||||
func ParseDue(s string) (time.Time, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// fakeLLM returns a canned reply and records the request, so a test can assert
|
||||
// on the grammar and on what of the mail was sent.
|
||||
type fakeLLM struct {
|
||||
reply string
|
||||
err error
|
||||
got llm.Req
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||
f.calls++
|
||||
f.got = r
|
||||
return f.reply, f.err
|
||||
}
|
||||
|
||||
func msgFor(subject, body string) Message {
|
||||
return Message{UID: 1, From: "anton@example.org", Subject: subject, Body: body}
|
||||
}
|
||||
|
||||
func TestExtractCandidates(t *testing.T) {
|
||||
f := &fakeLLM{reply: `[{"text":"отправить акт","due":""},{"text":"оплатить счёт","due":"2026-08-05"}]`}
|
||||
e := NewExtractor(f, 0, nil)
|
||||
got, err := e.Extract(context.Background(), msgFor("Акт и счёт", "Надо отправить акт и оплатить счёт до 5 августа."))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d candidates, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Text != "отправить акт" || got[1].Due != "2026-08-05" {
|
||||
t.Errorf("candidates = %+v", got)
|
||||
}
|
||||
if f.got.Grammar == "" {
|
||||
t.Error("extraction must be grammar-constrained")
|
||||
}
|
||||
// The subject and body go to the model; nothing else about the message does.
|
||||
if !strings.Contains(f.got.User, "Акт и счёт") || !strings.Contains(f.got.User, "оплатить счёт") {
|
||||
t.Errorf("user turn = %q", f.got.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmptyArrayIsNotAnError(t *testing.T) {
|
||||
f := &fakeLLM{reply: "[]"}
|
||||
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("FYI", "Просто к сведению."))
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("got (%v, %v), want (empty, nil) — no task is the normal answer", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Junk must never reach the model: the header filter exists so the resident
|
||||
// model is not spent on newsletters.
|
||||
func TestExtractSkipsJunkWithoutCallingModel(t *testing.T) {
|
||||
f := &fakeLLM{reply: `[{"text":"купить всё со скидкой","due":""}]`}
|
||||
msg := msgFor("Скидки", "Sale!")
|
||||
msg.Junk = true
|
||||
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msg)
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("got (%v, %v), want (nil, nil)", got, err)
|
||||
}
|
||||
if f.calls != 0 {
|
||||
t.Errorf("model called %d times for junk, want 0", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEmptyMessageIsNotSent(t *testing.T) {
|
||||
f := &fakeLLM{reply: "[]"}
|
||||
if _, err := NewExtractor(f, 0, nil).Extract(context.Background(), Message{UID: 3}); err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if f.calls != 0 {
|
||||
t.Errorf("model called %d times for an empty message, want 0", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCaps(t *testing.T) {
|
||||
f := &fakeLLM{reply: `[{"text":"a","due":""},{"text":"b","due":""},{"text":"c","due":""}]`}
|
||||
got, err := NewExtractor(f, 2, nil).Extract(context.Background(), msgFor("s", "b"))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("got %d, want the configured cap of 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDropsRepeatsAndBadDates(t *testing.T) {
|
||||
f := &fakeLLM{reply: `[{"text":"Отправить акт","due":"2026-02-31"},{"text":"отправить акт","due":""},{"text":" ","due":""}]`}
|
||||
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("s", "b"))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d candidates, want 1 (repeat and blank dropped): %+v", len(got), got)
|
||||
}
|
||||
if got[0].Due != "" {
|
||||
t.Errorf("due = %q, want empty — 2026-02-31 is not a date", got[0].Due)
|
||||
}
|
||||
}
|
||||
|
||||
// A Thinking model sometimes wraps the array; and when it emits something
|
||||
// unparsable the caller must hear about it rather than see "no tasks".
|
||||
func TestParseCandidatesTolerance(t *testing.T) {
|
||||
got, err := parseCandidates("думаю... [{\"text\":\"x\",\"due\":\"\"}] всё")
|
||||
if err != nil || len(got) != 1 || got[0].Text != "x" {
|
||||
t.Fatalf("got (%+v, %v)", got, err)
|
||||
}
|
||||
if _, err := parseCandidates("нет никакого JSON"); err == nil {
|
||||
t.Error("unparsable output must be an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractParseErrorHidesMailText(t *testing.T) {
|
||||
f := &fakeLLM{reply: "он просил отправить акт, вот такой ответ"}
|
||||
_, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("Акт", "секретный текст"))
|
||||
if err == nil {
|
||||
t.Fatal("want an error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "акт") || strings.Contains(err.Error(), "секретный") {
|
||||
t.Errorf("error text leaks mail content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDue(t *testing.T) {
|
||||
if _, ok := ParseDue(""); ok {
|
||||
t.Error("empty due must be (zero, false)")
|
||||
}
|
||||
if got, ok := ParseDue("2026-08-05"); !ok || got.Year() != 2026 || got.Month() != 8 || got.Day() != 5 {
|
||||
t.Errorf("ParseDue = (%v, %v)", got, ok)
|
||||
}
|
||||
if _, ok := ParseDue("05.08.2026"); ok {
|
||||
t.Error("a non-ISO date must not parse")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FetchSince is the whole read path in one call: connect, log in, examine the
|
||||
// mailbox read-only, list what arrived since a date, fetch and parse the ones
|
||||
// the caller has not seen, log out.
|
||||
//
|
||||
// It is a function rather than a long-lived object because a mail poller should
|
||||
// not hold an authenticated session (and therefore his credential in a live TLS
|
||||
// state) between polls. Connect, read, drop.
|
||||
//
|
||||
// skip decides which UIDs are already known — the poller's seen-set. max bounds
|
||||
// one poll: a mailbox that received 400 messages overnight must not turn into
|
||||
// 400 LLM calls, and the newest max are the ones a task could still be hiding
|
||||
// in. Junk messages are returned too, flagged, so the caller can mark them seen
|
||||
// without a second protocol round.
|
||||
type FetchSince struct {
|
||||
Addr string // host or host:993
|
||||
User string
|
||||
Mailbox string // e.g. "INBOX"
|
||||
Timeout time.Duration
|
||||
Since time.Time
|
||||
Max int
|
||||
Skip func(uid uint32) bool
|
||||
|
||||
// dial is the connection seam. nil means Dial (implicit TLS); the tests set
|
||||
// it to an in-process fake. Unexported so no configuration path can point
|
||||
// the reader at a non-TLS transport.
|
||||
dial func(addr string, timeout time.Duration) (*Conn, error)
|
||||
}
|
||||
|
||||
// Run performs one read. password is passed here, not stored in the struct, so
|
||||
// the configuration of a mailbox and the secret for it are never the same value
|
||||
// sitting in the same place.
|
||||
func (f FetchSince) Run(password string) ([]Message, error) {
|
||||
if f.Addr == "" || f.User == "" || f.Mailbox == "" {
|
||||
return nil, fmt.Errorf("email: mailbox not configured (addr/user/mailbox)")
|
||||
}
|
||||
dial := f.dial
|
||||
if dial == nil {
|
||||
dial = Dial
|
||||
}
|
||||
c, err := dial(f.Addr, f.Timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Close()
|
||||
if err := c.Login(f.User, password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Logout()
|
||||
if err := c.Select(f.Mailbox); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uids, err := c.SearchSince(f.Since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Newest UIDs first — IMAP hands them back ascending, and when Max clips the
|
||||
// list the recent mail is what matters.
|
||||
wanted := make([]uint32, 0, len(uids))
|
||||
for i := len(uids) - 1; i >= 0; i-- {
|
||||
if f.Skip != nil && f.Skip(uids[i]) {
|
||||
continue
|
||||
}
|
||||
wanted = append(wanted, uids[i])
|
||||
if f.Max > 0 && len(wanted) >= f.Max {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Message, 0, len(wanted))
|
||||
for _, uid := range wanted {
|
||||
raw, err := c.Fetch(uid)
|
||||
if err != nil {
|
||||
// One unreadable message does not abandon the poll; the rest of the
|
||||
// mailbox is still worth reading. The error names the UID, not the
|
||||
// message.
|
||||
return out, fmt.Errorf("email: fetch uid %d: %w", uid, err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
continue // vanished between SEARCH and FETCH
|
||||
}
|
||||
msg, err := ParseMessage(uid, raw)
|
||||
if err != nil {
|
||||
continue // unparsable headers — nothing to review, skip silently
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFetchSinceRun(t *testing.T) {
|
||||
mk := func(subject string) string {
|
||||
return "Subject: " + subject + "\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nbody\r\n"
|
||||
}
|
||||
f := &fakeIMAP{
|
||||
uids: []uint32{1, 2, 3},
|
||||
msgs: map[uint32]string{1: mk("one"), 2: mk("two"), 3: mk("three")},
|
||||
}
|
||||
fs := FetchSince{
|
||||
Addr: "mail.example:993", User: "kami", Mailbox: "INBOX",
|
||||
Timeout: 5 * time.Second,
|
||||
Since: time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC),
|
||||
Max: 2,
|
||||
Skip: func(uid uint32) bool { return uid == 3 },
|
||||
dial: func(addr string, timeout time.Duration) (*Conn, error) {
|
||||
cli, srv := net.Pipe()
|
||||
go f.serve(t, srv)
|
||||
return NewConn(cli, timeout)
|
||||
},
|
||||
}
|
||||
msgs, err := fs.Run("secret")
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
// Newest first, the already-seen UID skipped, Max respected.
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("got %d messages, want 2: %+v", len(msgs), msgs)
|
||||
}
|
||||
if msgs[0].Subject != "two" || msgs[1].Subject != "one" {
|
||||
t.Errorf("subjects = %q,%q, want two,one (newest first)", msgs[0].Subject, msgs[1].Subject)
|
||||
}
|
||||
if strings.Contains(strings.Join(f.cmds, " "), "UID FETCH 3") {
|
||||
t.Error("a skipped UID must not be fetched again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSinceRequiresConfig(t *testing.T) {
|
||||
if _, err := (FetchSince{}).Run("secret"); err == nil {
|
||||
t.Fatal("an unconfigured mailbox must not be read")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A minimal IMAP4rev1 client — LOGIN, SELECT, UID SEARCH, UID FETCH with
|
||||
// BODY.PEEK, LOGOUT, and nothing else.
|
||||
//
|
||||
// Why hand-rolled instead of go-imap: the whole surface Maven needs is five
|
||||
// commands, and this is the one code path that holds his mailbox credential and
|
||||
// reads his private mail. A ~200-line client with no dependencies is auditable
|
||||
// in one sitting; a general-purpose IMAP library is a much larger amount of
|
||||
// code doing much more than we asked, in the most sensitive place in the tree.
|
||||
// If IDLE, CONDSTORE or server-side threading ever become worth having, that
|
||||
// trade should be re-made deliberately.
|
||||
//
|
||||
// BODY.PEEK[] rather than BODY[] is load-bearing: Maven reads his mail and must
|
||||
// leave no trace of having done so. Reading a message here does not mark it
|
||||
// \Seen, so the unread state in his own mail client stays his.
|
||||
|
||||
// DefaultIMAPPort — implicit-TLS IMAP. There is no cleartext and no STARTTLS
|
||||
// path in this client: an option to send his password over a plain socket is an
|
||||
// option to get it wrong once.
|
||||
const DefaultIMAPPort = "993"
|
||||
|
||||
// Conn — one authenticated IMAP connection. Not safe for concurrent use; the
|
||||
// poller drives one connection at a time.
|
||||
type Conn struct {
|
||||
rwc io.ReadWriteCloser
|
||||
r *bufio.Reader
|
||||
tag int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// Dial opens an implicit-TLS connection and reads the server greeting.
|
||||
func Dial(addr string, timeout time.Duration) (*Conn, error) {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host, addr = addr, net.JoinHostPort(addr, DefaultIMAPPort)
|
||||
}
|
||||
d := &net.Dialer{Timeout: timeout}
|
||||
// ServerName is set from the host we asked for: certificate verification is
|
||||
// the only thing standing between his password and a MITM on the way out.
|
||||
c, err := tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: dial %s: %w", addr, err)
|
||||
}
|
||||
return NewConn(c, timeout)
|
||||
}
|
||||
|
||||
// NewConn wraps an already-open stream (the tests speak IMAP over a pipe) and
|
||||
// consumes the greeting.
|
||||
func NewConn(rwc io.ReadWriteCloser, timeout time.Duration) (*Conn, error) {
|
||||
c := &Conn{rwc: rwc, r: bufio.NewReaderSize(rwc, 64<<10), timeout: timeout}
|
||||
line, err := c.readLine()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: greeting: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "* OK") && !strings.HasPrefix(line, "* PREAUTH") {
|
||||
c.rwc.Close()
|
||||
return nil, fmt.Errorf("email: server refused connection: %s", line)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Conn) Close() error { return c.rwc.Close() }
|
||||
|
||||
// Login authenticates with LOGIN. The password is passed as an argument and
|
||||
// never stored on the Conn: nothing in this package keeps a credential alive
|
||||
// past the command that uses it, so no struct dump or panic trace can carry it.
|
||||
func (c *Conn) Login(user, pass string) error {
|
||||
// The command line itself is never logged (see exec) — a LOGIN line IS the
|
||||
// credential.
|
||||
if _, err := c.exec(fmt.Sprintf("LOGIN %s %s", quote(user), quote(pass))); err != nil {
|
||||
return fmt.Errorf("email: login: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Select opens a mailbox read-only. EXAMINE, not SELECT: read-only at the
|
||||
// protocol level means no command in this session can change a flag, expunge a
|
||||
// message, or move anything, even by mistake.
|
||||
func (c *Conn) Select(mailbox string) error {
|
||||
if _, err := c.exec(fmt.Sprintf("EXAMINE %s", quote(mailbox))); err != nil {
|
||||
return fmt.Errorf("email: examine %s: %w", mailbox, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchSince returns the UIDs of messages received on or after since. An
|
||||
// unlimited search is not offered: the first poll against a years-old mailbox
|
||||
// would otherwise fetch everything and hand a decade of mail to the model.
|
||||
//
|
||||
// The IMAP SINCE key has date granularity (and compares the server's internal
|
||||
// date), so the result can include messages slightly older than since. The
|
||||
// caller dedupes by UID anyway, so a wider window costs one extra fetch.
|
||||
func (c *Conn) SearchSince(since time.Time) ([]uint32, error) {
|
||||
cmd := fmt.Sprintf("UID SEARCH SINCE %s", since.Format("2-Jan-2006"))
|
||||
lines, err := c.exec(cmd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: search: %w", err)
|
||||
}
|
||||
var uids []uint32
|
||||
for _, l := range lines {
|
||||
rest, ok := untagged(l, "SEARCH")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, f := range strings.Fields(rest) {
|
||||
n, err := strconv.ParseUint(f, 10, 32)
|
||||
if err == nil {
|
||||
uids = append(uids, uint32(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
return uids, nil
|
||||
}
|
||||
|
||||
var literalSize = regexp.MustCompile(`\{(\d+)\}$`)
|
||||
|
||||
// Fetch returns the raw RFC 5322 bytes of one message, by UID.
|
||||
//
|
||||
// Returns (nil, nil) when the UID no longer exists — a message he deleted
|
||||
// between SEARCH and FETCH is normal, not an error.
|
||||
func (c *Conn) Fetch(uid uint32) ([]byte, error) {
|
||||
tag := c.nextTag()
|
||||
if err := c.send(fmt.Sprintf("%s UID FETCH %d (BODY.PEEK[])", tag, uid)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []byte
|
||||
for {
|
||||
line, err := c.readLine()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
|
||||
}
|
||||
if done, err := c.tagged(tag, line); done {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
m := literalSize.FindStringSubmatch(strings.TrimSpace(line))
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(c.r, buf); err != nil {
|
||||
return nil, fmt.Errorf("email: fetch %d: literal: %w", uid, err)
|
||||
}
|
||||
if raw == nil {
|
||||
raw = buf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logout ends the session politely. A failure is not worth reporting — the
|
||||
// connection is being closed either way.
|
||||
func (c *Conn) Logout() {
|
||||
_, _ = c.exec("LOGOUT")
|
||||
}
|
||||
|
||||
// ---- protocol plumbing -----------------------------------------------------
|
||||
|
||||
func (c *Conn) nextTag() string {
|
||||
c.tag++
|
||||
return fmt.Sprintf("a%03d", c.tag)
|
||||
}
|
||||
|
||||
// exec sends one command and returns the untagged response lines.
|
||||
//
|
||||
// Neither the command nor the response is ever logged here. LOGIN goes through
|
||||
// this function, and a debug line "sent: a001 LOGIN ..." is how a credential
|
||||
// ends up in a log file forever.
|
||||
func (c *Conn) exec(cmd string) ([]string, error) {
|
||||
tag := c.nextTag()
|
||||
if err := c.send(tag + " " + cmd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var lines []string
|
||||
for {
|
||||
line, err := c.readLine()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if done, err := c.tagged(tag, line); done {
|
||||
return lines, err
|
||||
}
|
||||
lines = append(lines, line)
|
||||
// A response line may carry a literal (e.g. a header FETCH). Nothing we
|
||||
// send asks for one outside Fetch, but skip it if it appears so the
|
||||
// stream stays aligned.
|
||||
if m := literalSize.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
|
||||
if n, err := strconv.Atoi(m[1]); err == nil {
|
||||
if _, err := io.CopyN(io.Discard, c.r, int64(n)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tagged reports whether line completes the command with this tag, and turns a
|
||||
// NO/BAD completion into an error. The error text is the server's, which never
|
||||
// echoes a password.
|
||||
func (c *Conn) tagged(tag, line string) (bool, error) {
|
||||
if !strings.HasPrefix(line, tag+" ") {
|
||||
return false, nil
|
||||
}
|
||||
rest := strings.TrimSpace(line[len(tag):])
|
||||
switch {
|
||||
case strings.HasPrefix(rest, "OK"):
|
||||
return true, nil
|
||||
case strings.HasPrefix(rest, "NO"), strings.HasPrefix(rest, "BAD"):
|
||||
return true, fmt.Errorf("server said: %s", rest)
|
||||
default:
|
||||
return true, fmt.Errorf("unexpected completion: %s", rest)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Conn) send(line string) error {
|
||||
c.setDeadline()
|
||||
if _, err := io.WriteString(c.rwc, line+"\r\n"); err != nil {
|
||||
return fmt.Errorf("email: write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Conn) readLine() (string, error) {
|
||||
c.setDeadline()
|
||||
line, err := c.r.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
|
||||
// setDeadline applies the per-connection timeout when the transport supports
|
||||
// one. A hung IMAP server must not park the poller forever.
|
||||
func (c *Conn) setDeadline() {
|
||||
if c.timeout <= 0 {
|
||||
return
|
||||
}
|
||||
if d, ok := c.rwc.(interface{ SetDeadline(time.Time) error }); ok {
|
||||
_ = d.SetDeadline(time.Now().Add(c.timeout))
|
||||
}
|
||||
}
|
||||
|
||||
// untagged splits "* SEARCH 1 2 3" into its payload when the key matches.
|
||||
func untagged(line, key string) (string, bool) {
|
||||
if !strings.HasPrefix(line, "* ") {
|
||||
return "", false
|
||||
}
|
||||
rest := strings.TrimSpace(line[2:])
|
||||
if !strings.HasPrefix(rest, key) {
|
||||
return "", false
|
||||
}
|
||||
return strings.TrimSpace(rest[len(key):]), true
|
||||
}
|
||||
|
||||
// quote renders an IMAP quoted string. Passwords routinely contain characters
|
||||
// that would otherwise end the argument early, and CR/LF are stripped rather
|
||||
// than escaped because there is no legal way to send them — a credential file
|
||||
// with a stray newline must not become a second command.
|
||||
func quote(s string) string {
|
||||
s = strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
||||
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeIMAP is a scripted server: enough of IMAP to exercise the client, and
|
||||
// nothing more. It records the commands it received so a test can assert on the
|
||||
// protocol (BODY.PEEK rather than BODY, EXAMINE rather than SELECT).
|
||||
type fakeIMAP struct {
|
||||
msgs map[uint32]string
|
||||
uids []uint32
|
||||
cmds []string
|
||||
failOn string // substring of a command to answer NO
|
||||
}
|
||||
|
||||
func (f *fakeIMAP) serve(t *testing.T, c net.Conn) {
|
||||
t.Helper()
|
||||
defer c.Close()
|
||||
fmt.Fprint(c, "* OK fake IMAP ready\r\n")
|
||||
r := bufio.NewReader(c)
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
return
|
||||
}
|
||||
tag, cmd := parts[0], parts[1]
|
||||
f.cmds = append(f.cmds, cmd)
|
||||
if f.failOn != "" && strings.Contains(cmd, f.failOn) {
|
||||
fmt.Fprintf(c, "%s NO computer says no\r\n", tag)
|
||||
continue
|
||||
}
|
||||
upper := strings.ToUpper(cmd)
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "LOGIN"), strings.HasPrefix(upper, "EXAMINE"):
|
||||
fmt.Fprintf(c, "%s OK done\r\n", tag)
|
||||
case strings.HasPrefix(upper, "UID SEARCH"):
|
||||
var ids []string
|
||||
for _, u := range f.uids {
|
||||
ids = append(ids, strconv.FormatUint(uint64(u), 10))
|
||||
}
|
||||
fmt.Fprintf(c, "* SEARCH %s\r\n", strings.Join(ids, " "))
|
||||
fmt.Fprintf(c, "%s OK search done\r\n", tag)
|
||||
case strings.HasPrefix(upper, "UID FETCH"):
|
||||
uid64, _ := strconv.ParseUint(strings.Fields(cmd)[2], 10, 32)
|
||||
raw, ok := f.msgs[uint32(uid64)]
|
||||
if ok {
|
||||
fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n", uid64, len(raw))
|
||||
fmt.Fprint(c, raw)
|
||||
fmt.Fprint(c, ")\r\n")
|
||||
}
|
||||
fmt.Fprintf(c, "%s OK fetch done\r\n", tag)
|
||||
case strings.HasPrefix(upper, "LOGOUT"):
|
||||
fmt.Fprint(c, "* BYE\r\n")
|
||||
fmt.Fprintf(c, "%s OK bye\r\n", tag)
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(c, "%s BAD unknown\r\n", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dialFake wires a client Conn to an in-process server over net.Pipe.
|
||||
func dialFake(t *testing.T, f *fakeIMAP) *Conn {
|
||||
t.Helper()
|
||||
cli, srv := net.Pipe()
|
||||
go f.serve(t, srv)
|
||||
c, err := NewConn(cli, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("greeting: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { c.Close() })
|
||||
return c
|
||||
}
|
||||
|
||||
func TestIMAPRoundTrip(t *testing.T) {
|
||||
body := "Subject: hello\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nCall the bank.\r\n"
|
||||
f := &fakeIMAP{uids: []uint32{4, 9}, msgs: map[uint32]string{4: body, 9: body}}
|
||||
c := dialFake(t, f)
|
||||
|
||||
if err := c.Login("kami", `pa"ss\word`); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if err := c.Select("INBOX"); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
uids, err := c.SearchSince(time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
if len(uids) != 2 || uids[0] != 4 || uids[1] != 9 {
|
||||
t.Fatalf("uids = %v, want [4 9]", uids)
|
||||
}
|
||||
raw, err := c.Fetch(9)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if string(raw) != body {
|
||||
t.Errorf("fetched %q, want the literal verbatim", raw)
|
||||
}
|
||||
c.Logout()
|
||||
|
||||
joined := strings.Join(f.cmds, "\n")
|
||||
// Read-only at the protocol level, and peeking — Maven must leave no trace
|
||||
// of having read his mail.
|
||||
if !strings.Contains(joined, "EXAMINE") || strings.Contains(joined, "SELECT ") {
|
||||
t.Errorf("want EXAMINE (read-only), got:\n%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "BODY.PEEK[]") {
|
||||
t.Errorf("want BODY.PEEK, got:\n%s", joined)
|
||||
}
|
||||
// The password must have been quoted and escaped, not truncated at the quote.
|
||||
if !strings.Contains(joined, `"pa\"ss\\word"`) {
|
||||
t.Errorf("password not quoted correctly:\n%s", joined)
|
||||
}
|
||||
// SINCE must carry the IMAP date form.
|
||||
if !strings.Contains(joined, "SINCE 1-Aug-2026") {
|
||||
t.Errorf("want a SINCE date, got:\n%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMAPServerNoIsAnError(t *testing.T) {
|
||||
f := &fakeIMAP{failOn: "LOGIN"}
|
||||
c := dialFake(t, f)
|
||||
err := c.Login("kami", "wrong")
|
||||
if err == nil {
|
||||
t.Fatal("a NO completion must be an error")
|
||||
}
|
||||
// The error is the server's text; it must not echo the credential.
|
||||
if strings.Contains(err.Error(), "wrong") {
|
||||
t.Errorf("error leaks the password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIMAPFetchMissingUID(t *testing.T) {
|
||||
f := &fakeIMAP{uids: []uint32{1}, msgs: map[uint32]string{}}
|
||||
c := dialFake(t, f)
|
||||
raw, err := c.Fetch(1)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if raw != nil {
|
||||
t.Errorf("a vanished UID should give nil, got %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteStripsNewlines(t *testing.T) {
|
||||
if got := quote("pass\r\nA1 LOGOUT"); strings.ContainsAny(got, "\r\n") {
|
||||
t.Errorf("quote kept a line break: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The junk filter — the cheapest and most important half of reading mail.
|
||||
//
|
||||
// A mailbox is mostly machine-generated: newsletters, receipts nobody acts on,
|
||||
// social notifications, marketing. Sending all of it to a 1.7B and asking "is
|
||||
// there a task here" produces confident nonsense at a rate proportional to the
|
||||
// volume, so junk is decided by HEADERS, before any model sees the message.
|
||||
//
|
||||
// The rules are all bulk-mail markers that senders set on themselves, never
|
||||
// guesses about content:
|
||||
//
|
||||
// - List-Unsubscribe / List-Id — by definition a mailing list. If he can
|
||||
// unsubscribe from it, it is not asking him to do anything.
|
||||
// - Precedence: bulk|junk|list — the sender declaring itself bulk.
|
||||
// - Auto-Submitted other than "no" (RFC 3834) — generated by a machine.
|
||||
// - X-Spam-Flag: YES, X-Spam-Status: Yes — the spam filter upstream already
|
||||
// decided; we do not second-guess it in the other direction.
|
||||
// - X-GM-LABELS / X-Gmail-Labels containing a Gmail category — Gmail's own
|
||||
// Promotions/Social/Forums/Spam classification, when the server sends it.
|
||||
//
|
||||
// Deliberately NOT here: sender allow/deny lists and subject keyword matching.
|
||||
// Both are configuration that ages badly and both would be a place for his
|
||||
// contacts to end up in a config file. If a real correspondent's mail is being
|
||||
// dropped, the fix is a rule about a header, not a list of names.
|
||||
//
|
||||
// A junk verdict never deletes anything and never touches a flag on the server.
|
||||
// It means "do not spend the model on this", nothing more.
|
||||
|
||||
// junkHeaders — headers whose mere presence marks bulk mail.
|
||||
var junkPresence = []string{"List-Unsubscribe", "List-Id", "List-Post"}
|
||||
|
||||
// gmailCategories — Gmail's category labels, lowercased as they appear in
|
||||
// X-GM-LABELS. "important" and "inbox" are labels too, and are NOT categories.
|
||||
// Matching is by these exact tokens (substring is fine — they are namespaced
|
||||
// and cannot appear in a hand-made label by accident), so a user label named
|
||||
// "Social Club" is not mistaken for Gmail's Social category.
|
||||
var gmailCategories = []string{
|
||||
"category_promotions", "category_social", "category_forums", "category_updates",
|
||||
`\spam`, `\junk`,
|
||||
}
|
||||
|
||||
// classifyJunk returns whether the message is bulk/automated and why. The
|
||||
// reason is a short header name, safe to log — it names the marker, never the
|
||||
// sender or the subject.
|
||||
func classifyJunk(h mail.Header) (bool, string) {
|
||||
for _, name := range junkPresence {
|
||||
if strings.TrimSpace(h.Get(name)) != "" {
|
||||
return true, strings.ToLower(name)
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(h.Get("Precedence"))) {
|
||||
case "bulk", "junk", "list":
|
||||
return true, "precedence"
|
||||
}
|
||||
if v := strings.ToLower(strings.TrimSpace(h.Get("Auto-Submitted"))); v != "" && v != "no" {
|
||||
return true, "auto-submitted"
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(h.Get("X-Spam-Flag")), "yes") {
|
||||
return true, "x-spam-flag"
|
||||
}
|
||||
if v := strings.ToLower(strings.TrimSpace(h.Get("X-Spam-Status"))); strings.HasPrefix(v, "yes") {
|
||||
return true, "x-spam-status"
|
||||
}
|
||||
labels := strings.ToLower(h.Get("X-GM-LABELS") + " " + h.Get("X-Gmail-Labels"))
|
||||
for _, c := range gmailCategories {
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(labels, c) {
|
||||
return true, "gmail-category"
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func headers(t *testing.T, raw string) mail.Header {
|
||||
t.Helper()
|
||||
m, err := mail.ReadMessage(strings.NewReader(strings.ReplaceAll(raw, "\n", "\r\n") + "\r\n\r\nbody\r\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("read headers: %v", err)
|
||||
}
|
||||
return m.Header
|
||||
}
|
||||
|
||||
func TestClassifyJunk(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
junk bool
|
||||
reason string
|
||||
}{
|
||||
{"personal", "From: a@b.c\nSubject: привет", false, ""},
|
||||
{"list-unsubscribe", "From: a@b.c\nList-Unsubscribe: <mailto:u@b.c>", true, "list-unsubscribe"},
|
||||
{"list-id", "From: a@b.c\nList-Id: <golang-nuts.example>", true, "list-id"},
|
||||
{"precedence bulk", "From: a@b.c\nPrecedence: bulk", true, "precedence"},
|
||||
{"auto-submitted", "From: a@b.c\nAuto-Submitted: auto-generated", true, "auto-submitted"},
|
||||
{"auto-submitted no", "From: a@b.c\nAuto-Submitted: no", false, ""},
|
||||
{"spam flag", "From: a@b.c\nX-Spam-Flag: YES", true, "x-spam-flag"},
|
||||
{"spam status", "From: a@b.c\nX-Spam-Status: Yes, score=9.1", true, "x-spam-status"},
|
||||
{"spam status no", "From: a@b.c\nX-Spam-Status: No, score=0.1", false, ""},
|
||||
{"gmail promo", "From: a@b.c\nX-Gmail-Labels: Inbox,CATEGORY_PROMOTIONS", true, "gmail-category"},
|
||||
{"user label", "From: a@b.c\nX-Gmail-Labels: Social Club,Important", false, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
junk, reason := classifyJunk(headers(t, c.raw))
|
||||
if junk != c.junk || reason != c.reason {
|
||||
t.Errorf("classifyJunk = (%v, %q), want (%v, %q)", junk, reason, c.junk, c.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewsletterFixtureIsJunk(t *testing.T) {
|
||||
msg, err := ParseMessage(9, fixture(t, "newsletter.eml"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if !msg.Junk {
|
||||
t.Fatal("a newsletter with List-Unsubscribe + Precedence: bulk must be junk")
|
||||
}
|
||||
// The reason is what gets logged, so it must never carry mail content.
|
||||
if strings.Contains(msg.JunkReason, "@") || strings.Contains(msg.JunkReason, "Скидки") {
|
||||
t.Errorf("junk reason leaks content: %q", msg.JunkReason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package email is the reading half of the email reader (Vikunja #246,
|
||||
// docs/plans/01-email-reader.md): a small IMAP client, a MIME-to-plaintext
|
||||
// converter, and the junk filter that decides a message is not worth reading at
|
||||
// all. Extraction lives in extract.go and writes nothing itself.
|
||||
//
|
||||
// Two constraints shape everything here, both from CLAUDE.md:
|
||||
//
|
||||
// - Mail is personal. Nothing in this package logs a body, a subject, or an
|
||||
// address; callers get the text and decide. Mail text is never search input
|
||||
// — no function here reaches the network except the IMAP connection itself.
|
||||
// - Off unless configured. There is no default host, no default account, and
|
||||
// no fallback that would make a mailbox get read because a field was empty.
|
||||
//
|
||||
// The IMAP subset is deliberately tiny (LOGIN, SELECT, UID SEARCH, UID FETCH
|
||||
// with BODY.PEEK, LOGOUT). No IDLE: a poll every few minutes is what a task
|
||||
// candidate needs, and IDLE would mean holding a connection and a credential
|
||||
// open forever for latency nobody is waiting on.
|
||||
package email
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxBodyBytes — how much of one message body is kept. A task hides in the
|
||||
// first screenful; the rest is signature, quoted history and legal boilerplate,
|
||||
// and it would only spend the resident model's 4096-token context.
|
||||
const MaxBodyBytes = 4000
|
||||
|
||||
// Message — one mail, reduced to the fields extraction and review need.
|
||||
//
|
||||
// Raw is deliberately absent: once a message is parsed the original bytes are
|
||||
// dropped, so no caller can accidentally log or forward the whole mail.
|
||||
type Message struct {
|
||||
UID uint32
|
||||
From string
|
||||
Subject string
|
||||
Date string // as sent, unparsed — display only
|
||||
Body string // plaintext, decoded, HTML-stripped, truncated
|
||||
// Junk is set by the junk filter (see junk.go). A junk message is carried
|
||||
// rather than dropped so the poller can count it and still mark it seen.
|
||||
Junk bool
|
||||
JunkReason string
|
||||
}
|
||||
|
||||
// ParseMessage turns one RFC 5322 message into a Message.
|
||||
//
|
||||
// It never fails on a body it cannot understand: an unparsable or
|
||||
// unsupported-charset body yields an empty Body and the headers still come
|
||||
// through, because a subject line alone is often the whole task ("Счёт за
|
||||
// интернет"). Only a message whose headers cannot be read at all is an error.
|
||||
func ParseMessage(uid uint32, raw []byte) (Message, error) {
|
||||
m, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("email: parse message: %w", err)
|
||||
}
|
||||
msg := Message{
|
||||
UID: uid,
|
||||
From: decodeHeader(m.Header.Get("From")),
|
||||
Subject: decodeHeader(m.Header.Get("Subject")),
|
||||
Date: m.Header.Get("Date"),
|
||||
}
|
||||
msg.Junk, msg.JunkReason = classifyJunk(m.Header)
|
||||
body, err := plaintextBody(m.Header.Get("Content-Type"), m.Header.Get("Content-Transfer-Encoding"), m.Body)
|
||||
if err == nil {
|
||||
msg.Body = truncate(collapse(body), MaxBodyBytes)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// plaintextBody walks the MIME tree and returns the best plaintext it can.
|
||||
//
|
||||
// Preference order inside a multipart: text/plain first, text/html stripped
|
||||
// only when there is no plain part. multipart/mixed attachments are skipped
|
||||
// wholesale — an attachment is a file, not a sentence, and reading one would
|
||||
// mean parsing arbitrary formats from the network.
|
||||
func plaintextBody(contentType, encoding string, body io.Reader) (string, error) {
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if contentType == "" || err != nil {
|
||||
// No Content-Type at all is legal and means text/plain; a broken one is
|
||||
// treated the same rather than dropping the message.
|
||||
mediaType, params = "text/plain", nil
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(mediaType, "multipart/"):
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
return "", fmt.Errorf("email: multipart without boundary")
|
||||
}
|
||||
return multipartText(multipart.NewReader(body, boundary))
|
||||
case mediaType == "text/html":
|
||||
raw, err := decodeBody(body, encoding, params["charset"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return stripHTML(raw), nil
|
||||
case mediaType == "text/plain":
|
||||
return decodeBody(body, encoding, params["charset"])
|
||||
default:
|
||||
// A single-part non-text message (a bare PDF, say). No body, headers only.
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// multipartText reads one multipart level, recursing into nested multiparts.
|
||||
// Returns the plain part if any part yielded one, else the stripped HTML.
|
||||
func multipartText(mr *multipart.Reader) (string, error) {
|
||||
var plain, html string
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
// A truncated multipart still gives up whatever came before it.
|
||||
break
|
||||
}
|
||||
if part.FileName() != "" {
|
||||
part.Close()
|
||||
continue // attachment
|
||||
}
|
||||
ct := part.Header.Get("Content-Type")
|
||||
mediaType, _, _ := mime.ParseMediaType(ct)
|
||||
text, err := plaintextBody(ct, part.Header.Get("Content-Transfer-Encoding"), part)
|
||||
part.Close()
|
||||
if err != nil || strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
}
|
||||
if mediaType == "text/html" && !strings.HasPrefix(mediaType, "multipart/") {
|
||||
if html == "" {
|
||||
html = text
|
||||
}
|
||||
continue
|
||||
}
|
||||
if plain == "" {
|
||||
plain = text
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(plain) != "" {
|
||||
return plain, nil
|
||||
}
|
||||
return html, nil
|
||||
}
|
||||
|
||||
// decodeBody applies the transfer encoding, then the charset.
|
||||
//
|
||||
// Charset support is UTF-8 (and ASCII, its subset) only, on purpose: x/text's
|
||||
// encoding tables are not vendored here, and guessing at windows-1251 bytes
|
||||
// would feed the model mojibake it would happily extract a task from. An
|
||||
// unsupported charset returns an error, which ParseMessage turns into an empty
|
||||
// body — subject-only, which is honest.
|
||||
func decodeBody(r io.Reader, encoding, charset string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "quoted-printable":
|
||||
r = quotedprintable.NewReader(r)
|
||||
case "base64":
|
||||
r = newBase64Reader(r)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(r, 1<<20))
|
||||
if err != nil && len(b) == 0 {
|
||||
return "", fmt.Errorf("email: read body: %w", err)
|
||||
}
|
||||
switch cs := strings.ToLower(strings.TrimSpace(charset)); cs {
|
||||
case "", "utf-8", "utf8", "us-ascii", "ascii":
|
||||
return string(b), nil
|
||||
default:
|
||||
return "", fmt.Errorf("email: unsupported charset %q", cs)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeHeader decodes RFC 2047 encoded words ("=?utf-8?B?...?="), which is how
|
||||
// every Russian subject line arrives. Undecodable headers come back as-is
|
||||
// rather than empty: a mangled subject is still a hint, and it is only ever
|
||||
// shown to him as evidence.
|
||||
func decodeHeader(v string) string {
|
||||
dec := new(mime.WordDecoder)
|
||||
out, err := dec.DecodeHeader(v)
|
||||
if err != nil {
|
||||
return collapse(v)
|
||||
}
|
||||
return collapse(out)
|
||||
}
|
||||
|
||||
var (
|
||||
scriptStyle = regexp.MustCompile(`(?is)<(script|style)\b[^>]*>.*?</\s*(script|style)\s*>`)
|
||||
htmlBreak = regexp.MustCompile(`(?i)<\s*(br\s*/?|/p|/div|/tr|/li|/h[1-6])\s*>`)
|
||||
htmlTag = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
htmlComment = regexp.MustCompile(`(?s)<!--.*?-->`)
|
||||
)
|
||||
|
||||
// stripHTML reduces an HTML part to text. A regex stripper, not a parser:
|
||||
// x/net/html is not vendored, and the consumer is a model reading prose — a
|
||||
// stray angle bracket costs nothing, whereas a new dependency for the privacy-
|
||||
// sensitive path costs review.
|
||||
func stripHTML(s string) string {
|
||||
s = scriptStyle.ReplaceAllString(s, " ")
|
||||
s = htmlComment.ReplaceAllString(s, " ")
|
||||
s = htmlBreak.ReplaceAllString(s, "\n")
|
||||
s = htmlTag.ReplaceAllString(s, " ")
|
||||
return unescapeEntities(s)
|
||||
}
|
||||
|
||||
var entities = strings.NewReplacer(
|
||||
" ", " ", "&", "&", "<", "<", ">", ">",
|
||||
""", `"`, "'", "'", "'", "'", "—", "—", "–", "–",
|
||||
)
|
||||
|
||||
func unescapeEntities(s string) string { return entities.Replace(s) }
|
||||
|
||||
// collapse squeezes runs of whitespace, keeping single newlines. Mail bodies
|
||||
// arrive with hard-wrapped lines and blocks of blank space; the model does not
|
||||
// need them and they are pure context budget.
|
||||
func collapse(s string) string {
|
||||
lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
|
||||
var out []string
|
||||
blank := 0
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(strings.Join(strings.Fields(l), " "))
|
||||
if l == "" {
|
||||
blank++
|
||||
if blank > 1 {
|
||||
continue
|
||||
}
|
||||
out = append(out, "")
|
||||
continue
|
||||
}
|
||||
blank = 0
|
||||
out = append(out, l)
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
|
||||
// truncate cuts to n bytes on a rune boundary.
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
cut := s[:n]
|
||||
for len(cut) > 0 && !isRuneStart(cut[len(cut)-1]) {
|
||||
cut = cut[:len(cut)-1]
|
||||
}
|
||||
return strings.TrimSpace(cut) + "…"
|
||||
}
|
||||
|
||||
func isRuneStart(b byte) bool { return b&0xC0 != 0x80 }
|
||||
|
||||
// newBase64Reader — base64.NewDecoder already skips the CRLFs mail bodies wrap
|
||||
// with, so this is only a named seam for decodeBody to read cleanly.
|
||||
func newBase64Reader(r io.Reader) io.Reader {
|
||||
return base64.NewDecoder(base64.StdEncoding, r)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture %s: %v", name, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestParsePlainRussian(t *testing.T) {
|
||||
msg, err := ParseMessage(7, fixture(t, "plain_ru.eml"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if msg.UID != 7 {
|
||||
t.Errorf("uid = %d, want 7", msg.UID)
|
||||
}
|
||||
if want := "Нужно закрыть задачу"; msg.Subject != want {
|
||||
t.Errorf("subject = %q, want %q", msg.Subject, want)
|
||||
}
|
||||
if !strings.Contains(msg.From, "Антон") {
|
||||
t.Errorf("from = %q, want the decoded display name", msg.From)
|
||||
}
|
||||
if !strings.Contains(msg.Body, "Надо отправить акт до пятницы.") {
|
||||
t.Errorf("body = %q, want the quoted-printable text decoded", msg.Body)
|
||||
}
|
||||
if msg.Junk {
|
||||
t.Errorf("a personal mail must not be junk (%s)", msg.JunkReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTMLOnlyIsStripped(t *testing.T) {
|
||||
msg, err := ParseMessage(1, fixture(t, "html_only.eml"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if strings.Contains(msg.Body, "<") || strings.Contains(msg.Body, "color:red") || strings.Contains(msg.Body, "x()") {
|
||||
t.Errorf("body still has markup/script/style: %q", msg.Body)
|
||||
}
|
||||
for _, want := range []string{"Счёт за интернет: 700", "Оплатить до 5 августа."} {
|
||||
if !strings.Contains(msg.Body, want) {
|
||||
t.Errorf("body = %q, want it to contain %q", msg.Body, want)
|
||||
}
|
||||
}
|
||||
// must have become a real space, not vanished into the number.
|
||||
if strings.Contains(msg.Body, " ") {
|
||||
t.Errorf("entity left unescaped: %q", msg.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePrefersPlainAndSkipsAttachments(t *testing.T) {
|
||||
msg, err := ParseMessage(2, fixture(t, "mixed_attachment.eml"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(msg.Body); got != "Sign the contract before Monday." {
|
||||
t.Errorf("body = %q, want the text/plain alternative only", got)
|
||||
}
|
||||
if strings.Contains(msg.Body, "PDF") {
|
||||
t.Errorf("attachment bytes leaked into the body: %q", msg.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// An unsupported charset must degrade to headers-only rather than to mojibake
|
||||
// the model would then extract a task from.
|
||||
func TestParseUnsupportedCharsetKeepsHeaders(t *testing.T) {
|
||||
msg, err := ParseMessage(3, fixture(t, "cp1251.eml"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if msg.Subject != "Legacy" {
|
||||
t.Errorf("subject = %q, want Legacy", msg.Subject)
|
||||
}
|
||||
if msg.Body != "" {
|
||||
t.Errorf("body = %q, want empty for an undecodable charset", msg.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTruncatesLongBody(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("Subject: long\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||
for i := 0; i < 2000; i++ {
|
||||
b.WriteString("длинная строка ")
|
||||
}
|
||||
msg, err := ParseMessage(4, []byte(b.String()))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(msg.Body) > MaxBodyBytes+8 {
|
||||
t.Errorf("body kept %d bytes, want ≤ %d", len(msg.Body), MaxBodyBytes)
|
||||
}
|
||||
if !strings.HasSuffix(msg.Body, "…") {
|
||||
t.Errorf("truncated body should be marked: %q", msg.Body[len(msg.Body)-20:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapseSqueezesBlankLines(t *testing.T) {
|
||||
got := collapse(" a b \r\n\r\n\r\n\r\n c \r\n")
|
||||
if got != "a b\n\nc" {
|
||||
t.Errorf("collapse = %q, want %q", got, "a b\n\nc")
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
From: legacy@example.org
|
||||
To: kami@example.org
|
||||
Subject: Legacy
|
||||
Date: Fri, 01 Aug 2026 05:00:00 +0400
|
||||
Content-Type: text/plain; charset="windows-1251"
|
||||
|
||||
Ï
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
From: billing@isp.example
|
||||
To: kami@example.org
|
||||
Subject: =?utf-8?B?0KHRh9GR0YIg0LfQsCDQuNC90YLQtdGA0L3QtdGC?=
|
||||
Date: Fri, 01 Aug 2026 08:00:00 +0400
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="B1"
|
||||
|
||||
--B1
|
||||
Content-Type: text/html; charset="utf-8"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
PGh0bWw+PGhlYWQ+PHN0eWxlPnB7Y29sb3I6cmVkfTwvc3R5bGU+PC9oZWFkPjxib2R5PjxwPtCh0YfRkdGCINC30LAg0LjQvdGC0LXRgNC90LXRgjogNzAwJm5ic3A74oK9PC9wPjxwPtCe0L/Qu9Cw0YLQuNGC0Ywg0LTQviA1INCw0LLQs9GD0YHRgtCwLjwvcD48c2NyaXB0PngoKTwvc2NyaXB0PjwvYm9keT48L2h0bWw+
|
||||
--B1--
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
From: hr@work.example
|
||||
To: kami@example.org
|
||||
Subject: Contract
|
||||
Date: Fri, 01 Aug 2026 07:00:00 +0400
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/mixed; boundary="M1"
|
||||
|
||||
--M1
|
||||
Content-Type: multipart/alternative; boundary="A1"
|
||||
|
||||
--A1
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
Sign the contract before Monday.
|
||||
--A1
|
||||
Content-Type: text/html; charset="utf-8"
|
||||
|
||||
<p>Sign the contract before Monday.</p>
|
||||
--A1--
|
||||
--M1
|
||||
Content-Type: application/pdf; name="contract.pdf"
|
||||
Content-Disposition: attachment; filename="contract.pdf"
|
||||
Content-Transfer-Encoding: base64
|
||||
|
||||
JVBERi0xLjQgbm90IHJlYWxseSBhIHBkZg==
|
||||
--M1--
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
From: news@shop.example
|
||||
To: kami@example.org
|
||||
Subject: =?utf-8?B?0KHQutC40LTQutC4INGC0L7Qu9GM0LrQviDRgdC10LPQvtC00L3Rjw==?=
|
||||
Date: Fri, 01 Aug 2026 06:00:00 +0400
|
||||
List-Unsubscribe: <mailto:unsub@shop.example>
|
||||
Precedence: bulk
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
Sale!
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
From: =?utf-8?B?0JDQvdGC0L7QvQ==?= <anton@example.org>
|
||||
To: kami@example.org
|
||||
Subject: =?utf-8?B?0J3Rg9C20L3QviDQt9Cw0LrRgNGL0YLRjCDQt9Cw0LTQsNGH0YM=?=
|
||||
Date: Fri, 01 Aug 2026 09:12:00 +0400
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
Message-ID: <plain-ru@example.org>
|
||||
|
||||
=D0=9F=D1=80=D0=B8=D0=B2=D0=B5=D1=82! =D0=9D=D0=B0=D0=B4=D0=BE =D0=BE=D1=82=
|
||||
=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C =D0=B0=D0=BA=D1=82 =D0=B4=D0=BE =
|
||||
=D0=BF=D1=8F=D1=82=D0=BD=D0=B8=D1=86=D1=8B.
|
||||
|
||||
--
|
||||
Anton
|
||||
@@ -138,6 +138,44 @@ type CaptureTaskResp struct {
|
||||
Created bool `json:"created"`
|
||||
}
|
||||
|
||||
// IngestMailReq — one message a mail reader has fetched, handed to core for
|
||||
// extraction (Vikunja #246).
|
||||
//
|
||||
// The mail reader (cmd/mavmaild) holds the IMAP credential and core never sees
|
||||
// it, the same split mavpoll uses for the zenmoney token. What crosses this
|
||||
// boundary is only the message text, because extraction runs on the resident
|
||||
// model and llama-server lives inside core's process.
|
||||
//
|
||||
// Body is already plaintext and truncated by internal/email; core does not
|
||||
// re-parse MIME and never stores the body. Junk means the reader's header
|
||||
// filter already classified the message as bulk — core is told rather than
|
||||
// asked, so a junk message can be counted without a model call.
|
||||
//
|
||||
// This method is available only when core has an email block configured AND a
|
||||
// llama-server phraser; otherwise it answers ErrUnknownMethod, which is what
|
||||
// "off unless configured" looks like at the wire.
|
||||
type IngestMailReq struct {
|
||||
Mailbox string `json:"mailbox"`
|
||||
UID uint32 `json:"uid"`
|
||||
From string `json:"from,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Junk bool `json:"junk,omitempty"`
|
||||
}
|
||||
|
||||
// IngestMailResp — what core did with the message. TaskIDs are the rows
|
||||
// CaptureTask returned; Created counts the ones that were new (a re-read
|
||||
// mailbox dedupes to Created=0). Skipped is set when nothing was asked of the
|
||||
// model at all — junk, or an empty message.
|
||||
//
|
||||
// Nothing here echoes the mail back. The reader logs counts.
|
||||
type IngestMailResp struct {
|
||||
TaskIDs []int64 `json:"task_ids,omitempty"`
|
||||
Created int `json:"created"`
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
}
|
||||
|
||||
type listTasksReq struct {
|
||||
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
|
||||
}
|
||||
|
||||
@@ -448,6 +448,17 @@ func (c *Client) SetTaskStatus(ctx context.Context, id int64, status string, ts
|
||||
return c.call(ctx, MethodSetTaskStatus, setTaskStatusReq{ID: id, Status: status, Ts: ts}, nil)
|
||||
}
|
||||
|
||||
// IngestMail hands one fetched message to core for extraction. ErrUnknownMethod
|
||||
// means core has no email block configured — the caller should stop asking, not
|
||||
// retry.
|
||||
func (c *Client) IngestMail(ctx context.Context, req IngestMailReq) (IngestMailResp, error) {
|
||||
var r IngestMailResp
|
||||
if err := c.call(ctx, MethodIngestMail, req, &r); err != nil {
|
||||
return IngestMailResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
|
||||
}
|
||||
|
||||
@@ -565,3 +565,36 @@ func mustJSON(v any) []byte {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestIngestMail_OffUnlessConfigured — with no IngestMailFn set (the default,
|
||||
// and what an unconfigured core looks like) the method does not exist. A mail
|
||||
// reader gets a refusal it can act on rather than a silent success.
|
||||
func TestIngestMail_OffUnlessConfigured(t *testing.T) {
|
||||
_, _, cli, _ := newServerWithStore(t)
|
||||
if _, err := cli.IngestMail(context.Background(), IngestMailReq{Mailbox: "INBOX", UID: 1}); !errors.Is(err, ErrUnknownMethod) {
|
||||
t.Fatalf("IngestMail error = %v, want ErrUnknownMethod", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestMail_Hook — when the daemon wires the hook, the message crosses the
|
||||
// boundary intact and the response comes back.
|
||||
func TestIngestMail_Hook(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
var got IngestMailReq
|
||||
srv.IngestMailFn = func(_ context.Context, req IngestMailReq) (IngestMailResp, error) {
|
||||
got = req
|
||||
return IngestMailResp{TaskIDs: []int64{7}, Created: 1}, nil
|
||||
}
|
||||
resp, err := cli.IngestMail(context.Background(), IngestMailReq{
|
||||
Mailbox: "INBOX", UID: 12, Subject: "Счёт", Body: "Оплатить.", Junk: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("IngestMail: %v", err)
|
||||
}
|
||||
if resp.Created != 1 || len(resp.TaskIDs) != 1 || resp.TaskIDs[0] != 7 {
|
||||
t.Errorf("resp = %+v", resp)
|
||||
}
|
||||
if got.UID != 12 || got.Subject != "Счёт" || got.Body != "Оплатить." {
|
||||
t.Errorf("req across the wire = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
+33
-4
@@ -421,6 +421,17 @@ type Server struct {
|
||||
// Set by the daemon; nil ⇒ MethodStoreEncryptionKey returns ErrUnknownMethod.
|
||||
WrapKeyFn WrapKeyFunc
|
||||
|
||||
// IngestMailFn — extracts task candidates from one fetched message. Set by
|
||||
// the daemon only when an email block is configured AND there is a
|
||||
// llama-server to extract with; nil ⇒ MethodIngestMail returns
|
||||
// ErrUnknownMethod, so a mail reader pointed at a core that is not
|
||||
// configured for mail is refused rather than silently ignored.
|
||||
//
|
||||
// Like StepUp/WrapKeyFn/UnlockFn this bypasses CoreAPI: it is not a store
|
||||
// operation, it needs the resident model, and it must not become a method
|
||||
// every CoreAPI implementation has to carry.
|
||||
IngestMailFn IngestMailFunc
|
||||
|
||||
// UnlockFn — unwraps the store encryption key from the wrapped blob using
|
||||
// the passkey credential public key, opens the encrypted store, and wires
|
||||
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
|
||||
@@ -439,6 +450,9 @@ type WrapKeyFunc func(ctx context.Context, publicKey []byte) error
|
||||
// public key and completes daemon initialization.
|
||||
type UnlockFunc func(ctx context.Context, publicKey []byte) error
|
||||
|
||||
// IngestMailFunc — core-side mail extraction. Returns what was captured.
|
||||
type IngestMailFunc func(ctx context.Context, req IngestMailReq) (IngestMailResp, error)
|
||||
|
||||
// CheckFunc — the auth hook signature. Wired by the daemon (auth.Gate.Check
|
||||
// satisfies this); dispatch calls it once per request after param-unmarshal
|
||||
// independence (it gets the raw params, may unmarshal what it needs — ipc
|
||||
@@ -606,9 +620,10 @@ func withoutParams[R any](fn func(ctx context.Context, api CoreAPI) (R, error))
|
||||
// existed) as an argument — so SetAPI's runtime swap (the unlock transition)
|
||||
// is still honored on the very next request with no extra plumbing here.
|
||||
//
|
||||
// MethodAssertStepUp, MethodStoreEncryptionKey and MethodUnlock are NOT in
|
||||
// this table: they bypass CoreAPI entirely (s.StepUp / s.WrapKeyFn /
|
||||
// s.UnlockFn), so dispatch special-cases them before consulting the table.
|
||||
// MethodAssertStepUp, MethodStoreEncryptionKey, MethodUnlock and
|
||||
// MethodIngestMail are NOT in this table: they bypass CoreAPI entirely
|
||||
// (s.StepUp / s.WrapKeyFn / s.UnlockFn / s.IngestMailFn), so dispatch
|
||||
// special-cases them before consulting the table.
|
||||
var methodTable = map[Method]handlerFunc{
|
||||
MethodWriteFact: withParams(func(ctx context.Context, api CoreAPI, p WriteFactReq) (idResp, error) {
|
||||
id, err := api.WriteFact(ctx, p)
|
||||
@@ -816,7 +831,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
}
|
||||
}
|
||||
|
||||
// These three bypass CoreAPI entirely — they drive Server fields set
|
||||
// These bypass CoreAPI entirely — they drive Server fields set
|
||||
// directly by the daemon (StepUp / WrapKeyFn / UnlockFn), not store
|
||||
// state, so they can never be table entries keyed on a CoreAPI method.
|
||||
switch req.Method {
|
||||
@@ -845,6 +860,20 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
return marshalResult(nil), s.UnlockFn(ctx, p.PublicKey)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
|
||||
case MethodIngestMail:
|
||||
if s.IngestMailFn != nil {
|
||||
var p IngestMailReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.IngestMailFn(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(resp), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
}
|
||||
|
||||
h, ok := methodTable[req.Method]
|
||||
|
||||
@@ -50,6 +50,7 @@ const (
|
||||
MethodCaptureTask Method = "capture_task"
|
||||
MethodListTasks Method = "list_tasks"
|
||||
MethodSetTaskStatus Method = "set_task_status"
|
||||
MethodIngestMail Method = "ingest_mail"
|
||||
)
|
||||
|
||||
// Request — one frame from module to core. Params is the JSON-encoded argument
|
||||
|
||||
Reference in New Issue
Block a user