@wirecat/cli-core 0.553.0 → 0.555.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/cli/messenger/catch-up-answers.d.ts +50 -1
  3. package/dist/cli/messenger/catch-up-answers.js +2 -1
  4. package/dist/cli/messenger/catch-up.d.ts +6 -2
  5. package/dist/cli/messenger/catch-up.js +11 -2
  6. package/dist/cli/messenger/conversation-answers.d.ts +3 -0
  7. package/dist/cli/messenger/inbox.js +1 -1
  8. package/dist/cli/messenger/message-answers.d.ts +3 -0
  9. package/dist/cli/messenger/message-writes.d.ts +6 -0
  10. package/dist/cli/messenger/points.d.ts +9 -4
  11. package/dist/cli/messenger/points.js +10 -4
  12. package/dist/cli/messenger/review.js +1 -1
  13. package/dist/cli/schedule/schedule-command.d.ts +0 -1
  14. package/dist/cli/schedule/schedule-command.js +6 -1
  15. package/dist/domain/models.d.ts +4 -0
  16. package/dist/entities/contact-context.d.ts +6 -0
  17. package/dist/entities/conversation-results.d.ts +6 -0
  18. package/dist/entities/evidence.d.ts +24 -0
  19. package/dist/entities/index.d.ts +263 -3
  20. package/dist/entities/index.js +1 -0
  21. package/dist/entities/listener.d.ts +12 -0
  22. package/dist/entities/message-context.d.ts +9 -0
  23. package/dist/entities/message-write.d.ts +12 -0
  24. package/dist/entities/message.d.ts +6 -0
  25. package/dist/entities/message.js +6 -0
  26. package/dist/entities/store-records.d.ts +3 -0
  27. package/dist/mail/cli/index.d.ts +2 -1
  28. package/dist/mail/cli/index.js +4 -1
  29. package/dist/mail/cli/review.d.ts +24 -0
  30. package/dist/mail/cli/review.js +204 -0
  31. package/dist/mail/email-input.d.ts +2 -0
  32. package/dist/mail/email-input.js +6 -1
  33. package/dist/mail/index.d.ts +2 -0
  34. package/dist/mail/index.js +2 -0
  35. package/dist/mail/owed.d.ts +11 -0
  36. package/dist/mail/owed.js +14 -0
  37. package/dist/mail/retry.js +2 -1
  38. package/dist/mail/review.d.ts +37 -0
  39. package/dist/mail/review.js +118 -0
  40. package/dist/mail/sync.js +5 -1
  41. package/dist/mail/testing/fake.d.ts +2 -0
  42. package/dist/mail/testing/fake.js +1 -1
  43. package/dist/services/agenda-mail.d.ts +15 -0
  44. package/dist/services/agenda-mail.js +66 -0
  45. package/dist/services/agenda.d.ts +4 -0
  46. package/dist/services/agenda.js +17 -7
  47. package/dist/store/exports.d.ts +1 -0
  48. package/dist/store/exports.js +1 -0
  49. package/dist/store/sqlite/email-activity.d.ts +62 -0
  50. package/dist/store/sqlite/email-activity.js +71 -0
  51. package/dist/store/sqlite/email-locations.d.ts +1 -0
  52. package/dist/store/sqlite/email-locations.js +4 -0
  53. package/dist/store/sqlite/emails.d.ts +9 -3
  54. package/dist/store/sqlite/emails.js +7 -14
  55. package/package.json +1 -1
@@ -0,0 +1,24 @@
1
+ import { Command } from "commander";
2
+ import type { MailReads } from "./index.js";
3
+ /** Where a mail review starts: a time only; one thread is `conversations show`. */
4
+ export declare const MAIL_REVIEW_QUERY: import("@wirecat/cli-base/query").QueryRegistry;
5
+ /** The mail review's steps; a host lists them beside its own so its step checks know them. */
6
+ export declare const MAIL_REVIEW_STEPS: ({
7
+ id: string;
8
+ kind: "command";
9
+ command: string;
10
+ args: {
11
+ name: string;
12
+ required: boolean;
13
+ }[];
14
+ why: string;
15
+ } | {
16
+ id: string;
17
+ kind: "user_action";
18
+ why: string;
19
+ })[];
20
+ /**
21
+ * `review` over the stored mail of the profile's mailbox, with tg's words: what is new, what the owner owes, what
22
+ * waits for an answer. It reads the store only and opens no task.
23
+ */
24
+ export declare const mailReviewCommand: (mail: MailReads) => Command;
@@ -0,0 +1,204 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { annotate } from "@wirecat/cli-base/commands";
3
+ import { defineQuery } from "@wirecat/cli-base/query";
4
+ import { defineSteps, stepOf } from "@wirecat/cli-base/standards";
5
+ import { Command } from "commander";
6
+ import { environmentOf, outputFor } from "../../cli/context.js";
7
+ import { LOCAL_READ } from "../../cli/effects.js";
8
+ import { listOf, renderAnswer } from "../../cli/envelope.js";
9
+ import { listQuery, nativeField, nativeTerms, queryHelp, typedQuery } from "../../cli/list-query.js";
10
+ import { pointsOf } from "../../cli/messenger/catch-up.js";
11
+ import { catchUpQuery, summaryOf } from "../../cli/messenger/catch-up-answers.js";
12
+ import { messageItem } from "../../cli/messenger/message-items.js";
13
+ import { checkPoints } from "../../cli/messenger/points.js";
14
+ import { unansweredHours } from "../../cli/messenger/review.js";
15
+ import { parseDuration } from "../../domain/duration.js";
16
+ import { formatReference } from "../../domain/references.js";
17
+ import { AGENDA_DEFAULTS } from "../../services/agenda.js";
18
+ import { REVIEW_DAYS, reviewStart } from "../../services/inbox.js";
19
+ import { hitText } from "../read.js";
20
+ import { OWED_DAYS, REVIEW_THREADS, reviewMail } from "../review.js";
21
+ const DAY = 86_400_000;
22
+ /** Where a mail review starts: a time only; one thread is `conversations show`. */
23
+ export const MAIL_REVIEW_QUERY = defineQuery({
24
+ entity: "message",
25
+ backend: "native",
26
+ fields: [
27
+ nativeField({ name: "date", type: "time", operators: ["range"], example: "date>2h" }),
28
+ nativeField({ name: "bulk", type: "kw", operators: ["term"], example: "bulk:false" }),
29
+ ],
30
+ });
31
+ /** The mail review's steps; a host lists them beside its own so its step checks know them. */
32
+ export const MAIL_REVIEW_STEPS = defineSteps([
33
+ {
34
+ id: "mail-review.next",
35
+ command: "review",
36
+ args: [{ name: "query", required: true }],
37
+ why: "the next review starts where this one ended",
38
+ },
39
+ {
40
+ id: "mail-conversations.show",
41
+ command: "conversations show",
42
+ args: [{ name: "conversation", required: true }],
43
+ why: "only the newest emails of the thread were read here; this reads it whole",
44
+ },
45
+ ]);
46
+ const step = (id) => MAIL_REVIEW_STEPS.find((one) => one.id === id);
47
+ const waitingHours = (value) => value === true ? AGENDA_DEFAULTS.waiting / 3_600_000 : parseDuration(String(value), "--waiting") / 3_600_000;
48
+ const quoted = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
49
+ /** A file name for the mailbox's points: they describe the mailbox, whichever profile reads it. */
50
+ const pointsName = (mailbox) => encodeURIComponent(mailbox.account);
51
+ const markText = (marks) => {
52
+ const said = [
53
+ marks?.bulk ? "bulk" : "",
54
+ marks?.listId ? `list ${marks.listId}` : "",
55
+ marks?.ruleId ? `rule ${marks.ruleId}` : "",
56
+ ];
57
+ const shown = said.filter(Boolean).join(" · ");
58
+ return shown ? `\n [${shown}]` : "";
59
+ };
60
+ const reviewAnswer = (found, mailbox, fresh) => {
61
+ const read = found.chats
62
+ .flatMap((chat) => chat.messages.map((message) => ({ ...message, chatTitle: chat.title })))
63
+ .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
64
+ const cut = found.chats.filter((chat) => chat.more);
65
+ const ref = (thread) => formatReference({ type: "chat", ...mailbox, chat: thread });
66
+ const turns = found.unanswered !== undefined || found.waiting !== undefined;
67
+ const missing = [
68
+ ...(cut.length > 0
69
+ ? [
70
+ {
71
+ field: "items",
72
+ why: `only the newest emails of ${cut.length} thread(s)`,
73
+ step: "mail-conversations.show",
74
+ },
75
+ ]
76
+ : []),
77
+ ...(found.skipped.length > 0
78
+ ? [
79
+ {
80
+ field: "items",
81
+ why: `skipped ${found.skipped.length} threads — more than ${REVIEW_THREADS} at once; the next review reads them`,
82
+ },
83
+ ]
84
+ : []),
85
+ ...(found.quiet > 0
86
+ ? [{ field: "items", why: `bulk:false left out ${found.quiet} threads of mail sent to many, lists or envelopes` }]
87
+ : []),
88
+ ];
89
+ const next = [
90
+ ...(!fresh && !turns
91
+ ? [
92
+ stepOf(step("mail-review.next"), {
93
+ args: { query: `date>${quoted(found.complete ? found.until : found.since)}` },
94
+ }),
95
+ ]
96
+ : []),
97
+ ...cut.map((chat) => stepOf(step("mail-conversations.show"), { args: { conversation: ref(chat.id) } })),
98
+ ];
99
+ return listOf("review", {
100
+ items: read.map((message) => ({
101
+ ...summaryOf(message, mailbox),
102
+ email: {
103
+ ...messageItem(message, mailbox).email,
104
+ bulk: message.marks?.bulk ?? false,
105
+ list_id: message.marks?.listId ?? null,
106
+ rule_id: message.marks?.ruleId ?? null,
107
+ },
108
+ })),
109
+ limit: Math.max(1, read.length),
110
+ has_more: false,
111
+ next,
112
+ missing,
113
+ meta: found.complete ? {} : { complete: false },
114
+ });
115
+ };
116
+ /**
117
+ * `review` over the stored mail of the profile's mailbox, with tg's words: what is new, what the owner owes, what
118
+ * waits for an answer. It reads the store only and opens no task.
119
+ */
120
+ export const mailReviewCommand = (mail) => annotate(new Command("review"), {
121
+ examples: [
122
+ `${mail.app.command} personal review "date>2h"`,
123
+ `${mail.app.command} personal review --new`,
124
+ `${mail.app.command} personal review --unanswered 1d`,
125
+ `${mail.app.command} personal review --waiting 7d`,
126
+ ],
127
+ effects: LOCAL_READ,
128
+ partial: true,
129
+ output: { entity: "message", view: "summary" },
130
+ query: MAIL_REVIEW_QUERY,
131
+ })
132
+ .summary("threads with new mail, mail you owe an answer, mail waiting for one")
133
+ .description("every stored email, yours too, in threads that changed since a point — for reviewing who owes what. " +
134
+ "It reads what sync stored, so it asks the mail server nothing")
135
+ .addHelpText("after", queryHelp(MAIL_REVIEW_QUERY))
136
+ .argument("[query...]", `where the last review ended: date>2h; ${REVIEW_DAYS} days ago if no date`)
137
+ .option("--unanswered [duration]", "only threads whose newest email came to you, in To or Cc, with no answer for this long — 4h, 1d; 24h if not given")
138
+ .option("--waiting [duration]", "only threads where you wrote last and nobody answered for this long — 3d; 7d if not given")
139
+ .option("--new", "what changed since the last `review --new` of this mailbox, a point per thread — for scheduled runs")
140
+ .addHelpText("after", `
141
+ Without a date, --unanswered and --waiting look at threads active in the last ${OWED_DAYS} days.
142
+ Every email is shown, mail sent to many too; each item's email says bulk, list_id and rule_id, and its sender_score.
143
+ bulk:false leaves out threads whose newest email is mail sent to many, a list's or recorded by an envelope rule.
144
+ Mail newer than the last finished sync waits for the next review. Each reader keeps its own --new point: you,
145
+ an agent over MCP, and each scheduled run.`)
146
+ .action(async function (query) {
147
+ const options = this.opts();
148
+ const { since: from, meta } = catchUpQuery(query, MAIL_REVIEW_QUERY, "review");
149
+ const bulk = nativeTerms(listQuery(typedQuery(query), MAIL_REVIEW_QUERY, "review"), "review").get("bulk")?.value;
150
+ if (bulk !== undefined && bulk !== "false")
151
+ throw new CliError("validation_error", `bulk: takes false — leave it out to see all mail, not "${bulk}"`);
152
+ const turns = options.unanswered !== undefined || options.waiting !== undefined;
153
+ if (options.new && (from !== undefined || turns))
154
+ throw new CliError("validation_error", "--new keeps its own point — not with a date, --unanswered or --waiting");
155
+ const unanswered = options.unanswered === undefined ? undefined : unansweredHours(options.unanswered);
156
+ const waiting = options.waiting === undefined ? undefined : waitingHours(options.waiting);
157
+ const env = environmentOf(this).env ?? process.env;
158
+ const now = Date.now();
159
+ const { found, mailbox, saved } = await mail.read(this, async (store, mailbox) => {
160
+ const saved = options.new
161
+ ? checkPoints(mail.app, {
162
+ command: pointsOf(this, "review", mail.app),
163
+ profile: pointsName(mailbox),
164
+ env,
165
+ firstLookMs: REVIEW_DAYS * DAY,
166
+ })
167
+ : undefined;
168
+ const since = from ?? saved?.first ?? (turns ? now - OWED_DAYS * DAY : reviewStart(now));
169
+ const found = await reviewMail(store, mailbox, {
170
+ since,
171
+ now,
172
+ ...(saved ? { points: saved.chats } : {}),
173
+ ...(bulk === "false" ? { noBulk: true } : {}),
174
+ ...(unanswered === undefined ? {} : { unansweredAfterHours: unanswered }),
175
+ ...(waiting === undefined ? {} : { waitingAfterHours: waiting }),
176
+ });
177
+ return { found, mailbox, saved };
178
+ });
179
+ const output = outputFor(this);
180
+ const answer = reviewAnswer(found, mailbox, saved !== undefined);
181
+ if (meta)
182
+ answer.meta = { ...meta, ...answer.meta };
183
+ renderAnswer(this, output, answer, () => {
184
+ const read = found.chats.flatMap((chat) => chat.messages);
185
+ if (found.unanswered || found.waiting)
186
+ output.renderer.note(`${[
187
+ found.unanswered ? `owed for ${found.unanswered.olderThanHours}h or more` : "",
188
+ found.waiting ? `waiting ${found.waiting.olderThanHours}h or more` : "",
189
+ ]
190
+ .filter(Boolean)
191
+ .join(", ")}, threads active from ${found.since}`);
192
+ else
193
+ output.renderer.note(`from ${found.since} to ${found.until}${found.complete ? "" : ", incomplete"}`);
194
+ if (found.quiet > 0)
195
+ output.renderer.note(`bulk:false left out ${found.quiet} threads`);
196
+ if (read.length === 0)
197
+ output.renderer.note("nothing new in the stored mail");
198
+ return read
199
+ .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
200
+ .map((hit) => `${hitText(hit)}${markText(hit.marks)}\n`)
201
+ .join("");
202
+ });
203
+ saved?.save(found.checked ?? {}, found.complete ? { through: found.until } : {});
204
+ });
@@ -1,5 +1,7 @@
1
1
  import type { EmailInput } from "../store/index.js";
2
2
  import type { ParsedMail } from "./parse.js";
3
3
  import type { Listed } from "./source.js";
4
+ /** The Date header, or the server's internal date when the header is missing or unreadable. */
5
+ export declare const sentAtOf: (date: string | null, receivedAt: string) => number;
4
6
  /** What one read email stores of itself, whichever run read it; where it is filed is the caller's. */
5
7
  export declare const parsedEmail: (mail: ParsedMail, item: Listed, header: Record<string, string | boolean>, address: string) => Omit<EmailInput, "mailboxes">;
@@ -1,4 +1,9 @@
1
1
  import { addressOf } from "./attachments.js";
2
+ /** The Date header, or the server's internal date when the header is missing or unreadable. */
3
+ export const sentAtOf = (date, receivedAt) => {
4
+ const sent = date === null ? Number.NaN : Date.parse(date);
5
+ return Number.isNaN(sent) ? Date.parse(receivedAt) : sent;
6
+ };
2
7
  /** What one read email stores of itself, whichever run read it; where it is filed is the caller's. */
3
8
  export const parsedEmail = (mail, item, header, address) => ({
4
9
  externalId: item.messageId,
@@ -7,7 +12,7 @@ export const parsedEmail = (mail, item, header, address) => ({
7
12
  to: mail.to.map(addressOf),
8
13
  cc: mail.cc.map(addressOf),
9
14
  bcc: mail.bcc.map(addressOf),
10
- sentAt: null,
15
+ sentAt: sentAtOf(mail.date, item.receivedAt),
11
16
  receivedAt: Date.parse(item.receivedAt),
12
17
  bodyText: mail.text || null,
13
18
  size: item.size ?? null,
@@ -6,6 +6,7 @@ export { dropMissing, type Gone, tooManyGone } from "./gone.js";
6
6
  export { type AuthVerdicts, HEADER_FIELDS, headersOf, type MailHeaders, mailHeadersOf } from "./headers.js";
7
7
  export { type MailIdsInput, mailIds } from "./ids.js";
8
8
  export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
9
+ export { type MailTurn, mailTurn } from "./owed.js";
9
10
  export { type Address, htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
10
11
  export { PARTIAL, type PartialOutcome } from "./partial.js";
11
12
  export { PRESET_NAMES, PRESETS, type PresetName, presetMatches } from "./presets.js";
@@ -13,6 +14,7 @@ export { domainsToAge, groupKey, type Probe, type ProbeGroup, probeMail, SUGGEST
13
14
  export { type MailFolderProgress, mailProgress } from "./progress.js";
14
15
  export { ATTACHMENT_CHARS, attachmentsText, hitText, lowerSuspicious, type MailSearch, type ShownMail, searchMail, showMail, withAttachmentText, } from "./read.js";
15
16
  export { type FileStamp, FULL_PASS_MS, MBOX_RESUME, type MboxPoint, mergeRanges, parsePoint, type Ranges, type ResumePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
17
+ export { isBulk, type MailReview, type MailReviewOptions, OWED_DAYS, REVIEW_THREADS, reviewMail } from "./review.js";
16
18
  export { ruleInput, STORE_ACTION } from "./rule-input.js";
17
19
  export { type MailRuleSet, storedMailRule, storedMailRuleSet, storedMailRules } from "./rule-stored.js";
18
20
  export { mapProposedTags, ruleTagger, settleRuleTags, type TagOutcome } from "./rule-tags.js";
@@ -6,6 +6,7 @@ export { dropMissing, tooManyGone } from "./gone.js";
6
6
  export { HEADER_FIELDS, headersOf, mailHeadersOf } from "./headers.js";
7
7
  export { mailIds } from "./ids.js";
8
8
  export { decodedLabelName, decodeMailboxName, labelName, systemLabel } from "./labels.js";
9
+ export { mailTurn } from "./owed.js";
9
10
  export { htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, parseMail, } from "./parse.js";
10
11
  export { PARTIAL } from "./partial.js";
11
12
  export { PRESET_NAMES, PRESETS, presetMatches } from "./presets.js";
@@ -13,6 +14,7 @@ export { domainsToAge, groupKey, probeMail, SUGGEST_RULE } from "./probe.js";
13
14
  export { mailProgress } from "./progress.js";
14
15
  export { ATTACHMENT_CHARS, attachmentsText, hitText, lowerSuspicious, searchMail, showMail, withAttachmentText, } from "./read.js";
15
16
  export { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
17
+ export { isBulk, OWED_DAYS, REVIEW_THREADS, reviewMail } from "./review.js";
16
18
  export { ruleInput, STORE_ACTION } from "./rule-input.js";
17
19
  export { storedMailRule, storedMailRuleSet, storedMailRules } from "./rule-stored.js";
18
20
  export { mapProposedTags, ruleTagger, settleRuleTags } from "./rule-tags.js";
@@ -0,0 +1,11 @@
1
+ import type { NewestEmail } from "../store/store.js";
2
+ /** Whose turn a thread is: the owner owes an answer, waits for one, or neither. */
3
+ export type MailTurn = "owed" | "waiting" | null;
4
+ /**
5
+ * **The one place that says what the owner owes in mail** (`review --unanswered`, `--waiting`, `tasks agenda`).
6
+ * Owed: the newest email came in and names the owner in To or Cc. Mail sent to many, lists and envelopes count too
7
+ * (the owner's ruling: look at all mail); each item carries its marks instead, for the reader to sort. No question
8
+ * detection. Waiting: the owner wrote last. Mail answered from another client counts, since its copy in Sent or All
9
+ * Mail is synced.
10
+ */
11
+ export declare const mailTurn: (newest: NewestEmail | null) => MailTurn;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * **The one place that says what the owner owes in mail** (`review --unanswered`, `--waiting`, `tasks agenda`).
3
+ * Owed: the newest email came in and names the owner in To or Cc. Mail sent to many, lists and envelopes count too
4
+ * (the owner's ruling: look at all mail); each item carries its marks instead, for the reader to sort. No question
5
+ * detection. Waiting: the owner wrote last. Mail answered from another client counts, since its copy in Sent or All
6
+ * Mail is synced.
7
+ */
8
+ export const mailTurn = (newest) => {
9
+ if (newest === null)
10
+ return null;
11
+ if (newest.outgoing)
12
+ return "waiting";
13
+ return newest.addressed ? "owed" : null;
14
+ };
@@ -1,6 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { mailAccountKey } from "./account-key.js";
3
3
  import { addressOf, FETCH_CHUNK, readAttachments } from "./attachments.js";
4
+ import { sentAtOf } from "./email-input.js";
4
5
  import { mailFolders } from "./folders.js";
5
6
  import { mailHeadersOf } from "./headers.js";
6
7
  import { parseMail } from "./parse.js";
@@ -74,7 +75,7 @@ export const retryUnread = async ({ store, source, account, since, max, maxBodyC
74
75
  to: mail.to.map(addressOf),
75
76
  cc: mail.cc.map(addressOf),
76
77
  bcc: mail.bcc.map(addressOf),
77
- sentAt: null,
78
+ sentAt: sentAtOf(mail.date, item.receivedAt),
78
79
  receivedAt: Date.parse(item.receivedAt),
79
80
  bodyText: mail.text || null,
80
81
  size: item.size ?? stored.size,
@@ -0,0 +1,37 @@
1
+ import { type Review, type ReviewChat } from "../domain/models.js";
2
+ import type { AccountKey, EmailMarks, MessageStore, NewestEmail, StoredHit } from "../store/store.js";
3
+ /** Every thread is a local read; this many keep one answer readable, and the rest wait in `skipped`. */
4
+ export declare const REVIEW_THREADS = 50;
5
+ /** Without a date, `--unanswered` and `--waiting` look as far back as `tasks agenda` does. */
6
+ export declare const OWED_DAYS = 30;
7
+ export interface MailReviewOptions {
8
+ /** ms: where a thread with no point of its own starts. */
9
+ since: number;
10
+ /** A thread's own start, by its external id. */
11
+ points?: ReadonlyMap<string, number>;
12
+ /** Leave out threads whose newest email is mail sent to many, a list's or an envelope (`bulk:false`). */
13
+ noBulk?: boolean;
14
+ /** Only threads the owner owes an answer for at least this many hours (`mailTurn`). */
15
+ unansweredAfterHours?: number;
16
+ /** Only threads where the owner wrote last and nobody answered for at least this many hours. */
17
+ waitingAfterHours?: number;
18
+ now?: number;
19
+ }
20
+ /** A review of mail: each thread a chat, each email a stored hit with its locator. */
21
+ export type MailHit = StoredHit & {
22
+ marks?: EmailMarks;
23
+ };
24
+ export interface MailReview extends Review {
25
+ chats: (Omit<ReviewChat, "messages"> & {
26
+ messages: MailHit[];
27
+ })[];
28
+ }
29
+ /** Mail sent to many, a list's, or recorded by an envelope rule without its text. */
30
+ export declare const isBulk: (email: NewestEmail) => boolean;
31
+ /**
32
+ * **Every email, both sides, in each thread that changed since a point** — `review` over the store's mail, in
33
+ * messenger review's shape: a thread is a chat, an email a message. Reads the store alone, so it costs the mail
34
+ * server nothing; it opens no task (the owner reviews mail tasks first). Cut where sync proved the mailbox
35
+ * (`syncedThrough`), so the next review starting at `until` misses nothing sync had stored by then.
36
+ */
37
+ export declare const reviewMail: (store: MessageStore, mailbox: AccountKey, options: MailReviewOptions) => Promise<MailReview>;
@@ -0,0 +1,118 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { senderIsMe } from "../domain/models.js";
3
+ import { mailTurn } from "./owed.js";
4
+ /** Every thread is a local read; this many keep one answer readable, and the rest wait in `skipped`. */
5
+ export const REVIEW_THREADS = 50;
6
+ const REVIEW_PER_THREAD = 50;
7
+ /** Without a date, `--unanswered` and `--waiting` look as far back as `tasks agenda` does. */
8
+ export const OWED_DAYS = 30;
9
+ // A thread is picked by its own time, which is the Date header where sync stored one; mail delivered up to a day
10
+ // after its date is still found.
11
+ const DATED_SLACK = 86_400_000;
12
+ const HOUR = 3_600_000;
13
+ /** Mail sent to many, a list's, or recorded by an envelope rule without its text. */
14
+ export const isBulk = (email) => email.bulk || email.list || email.envelope;
15
+ const absent = (error) => {
16
+ if (error instanceof CliError && error.code === "not_found")
17
+ return undefined;
18
+ throw error;
19
+ };
20
+ /**
21
+ * How far sync proved the mailbox: everything that arrived before `until` is stored. Review cuts there, so mail a
22
+ * running sync has not stored yet cannot slip behind a saved point.
23
+ */
24
+ const syncedThrough = async (store, mailbox) => {
25
+ const saved = await store.sync.syncState(mailbox, "mail_coverage");
26
+ if (!saved)
27
+ return { until: undefined, complete: false };
28
+ try {
29
+ const { until, state } = JSON.parse(saved.value);
30
+ const at = typeof until === "string" ? Date.parse(until) : Number.NaN;
31
+ return { until: Number.isNaN(at) ? undefined : at, complete: state === "window" && !Number.isNaN(at) };
32
+ }
33
+ catch {
34
+ return { until: undefined, complete: false };
35
+ }
36
+ };
37
+ const wanted = (thread, { noBulk, unansweredAfterHours, waitingAfterHours }, now) => {
38
+ const newest = thread.newest;
39
+ if (unansweredAfterHours !== undefined || waitingAfterHours !== undefined) {
40
+ const turn = mailTurn(newest);
41
+ const age = newest === null ? 0 : now - newest.at;
42
+ const owed = turn === "owed" && unansweredAfterHours !== undefined && age >= unansweredAfterHours * HOUR;
43
+ const waiting = turn === "waiting" && waitingAfterHours !== undefined && age >= waitingAfterHours * HOUR;
44
+ if (!owed && !waiting)
45
+ return "no";
46
+ }
47
+ return noBulk && newest !== null && !newest.outgoing && isBulk(newest) ? "quiet" : "read";
48
+ };
49
+ /** The emails after the other side last wrote: what is owed, or what waits for an answer. */
50
+ const turnTail = (messages, owner, ownerWrote) => {
51
+ const start = messages.findLastIndex((one) => senderIsMe(one, owner) !== ownerWrote);
52
+ return messages.slice(start + 1);
53
+ };
54
+ /**
55
+ * **Every email, both sides, in each thread that changed since a point** — `review` over the store's mail, in
56
+ * messenger review's shape: a thread is a chat, an email a message. Reads the store alone, so it costs the mail
57
+ * server nothing; it opens no task (the owner reviews mail tasks first). Cut where sync proved the mailbox
58
+ * (`syncedThrough`), so the next review starting at `until` misses nothing sync had stored by then.
59
+ */
60
+ export const reviewMail = async (store, mailbox, options) => {
61
+ const { since, points } = options;
62
+ const now = options.now ?? Date.now();
63
+ const account = await store.accounts.storedAccount(mailbox).catch(absent);
64
+ const synced = account ? await syncedThrough(store, mailbox) : { until: undefined, complete: false };
65
+ const cut = Math.min(now, synced.until ?? now);
66
+ const startOf = (thread) => points?.get(thread) ?? since;
67
+ const activity = account ? await store.mail.threadActivity(account.id, since - DATED_SLACK) : [];
68
+ const changed = activity.filter((thread) => Math.max(thread.lastEmailAt, thread.newest?.at ?? 0) > startOf(thread.threadId));
69
+ const picked = changed.map((thread) => ({ thread, as: wanted(thread, options, now) }));
70
+ const shown = picked.filter(({ as }) => as === "read").map(({ thread }) => thread);
71
+ const read = shown.slice(0, REVIEW_THREADS);
72
+ const skipped = shown.slice(REVIEW_THREADS).map(({ threadId, subject, lastEmailAt }) => ({
73
+ id: threadId,
74
+ title: subject,
75
+ lastMessageAt: new Date(lastEmailAt).toISOString(),
76
+ }));
77
+ const turns = options.unansweredAfterHours !== undefined || options.waitingAfterHours !== undefined;
78
+ const chats = [];
79
+ const checked = {};
80
+ for (const thread of read) {
81
+ const hits = await store.mail.threadHits(account?.id ?? 0, thread.threadId, {
82
+ after: startOf(thread.threadId),
83
+ until: cut,
84
+ });
85
+ const tail = turns ? turnTail(hits, mailbox.account, thread.newest?.outgoing === true) : hits;
86
+ const marks = await store.mail.emailMarks(account?.id ?? 0, tail.map((hit) => hit.id));
87
+ const messages = tail.map((hit) => {
88
+ const mark = marks.get(hit.id);
89
+ return mark ? { ...hit, marks: mark } : hit;
90
+ });
91
+ const more = messages.length > REVIEW_PER_THREAD;
92
+ if (messages.length > 0)
93
+ chats.push({
94
+ id: thread.threadId,
95
+ title: thread.subject,
96
+ kind: "dialog",
97
+ messages: more ? messages.slice(-REVIEW_PER_THREAD) : messages,
98
+ more,
99
+ });
100
+ if (!more)
101
+ checked[thread.threadId] = new Date(cut).toISOString();
102
+ }
103
+ const earliest = changed.reduce((low, thread) => Math.min(low, startOf(thread.threadId)), Number.POSITIVE_INFINITY);
104
+ return {
105
+ since: new Date(points === undefined || changed.length === 0 ? since : earliest).toISOString(),
106
+ until: new Date(cut).toISOString(),
107
+ complete: synced.complete && skipped.length === 0 && chats.every((one) => !one.more),
108
+ chats,
109
+ skipped,
110
+ partial: false,
111
+ quiet: picked.filter(({ as }) => as === "quiet").length,
112
+ ...(options.unansweredAfterHours === undefined
113
+ ? {}
114
+ : { unanswered: { olderThanHours: options.unansweredAfterHours } }),
115
+ ...(options.waitingAfterHours === undefined ? {} : { waiting: { olderThanHours: options.waitingAfterHours } }),
116
+ ...(points === undefined ? {} : { checked }),
117
+ };
118
+ };
package/dist/mail/sync.js CHANGED
@@ -24,6 +24,8 @@ const DAY = 86_400_000;
24
24
  const EDGE = 2 * DAY;
25
25
  /** How often a long run saves where it is, so a kill costs at most this much listing again. */
26
26
  const SAVE_EVERY_MS = 30_000;
27
+ /** About 55 runs clear a 276,000-email account; each is a short write that does not hold the sync up. */
28
+ const SENT_AT_BACKFILL_PER_RUN = 5000;
27
29
  const SLICE_SIZE = 50;
28
30
  const SLICE_PAUSE_MS = 100;
29
31
  /** About a minute of listing; the 30-day window of the timer stays below it. */
@@ -682,7 +684,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
682
684
  // Subject and text stay out of the columns search reads, so recorded mail is never a search hit.
683
685
  subject: null,
684
686
  from: headers?.from === undefined ? null : { address: headers.from, name: headers.fromName ?? null },
685
- sentAt: null,
687
+ sentAt: Date.parse(item.receivedAt),
686
688
  receivedAt: Date.parse(item.receivedAt),
687
689
  bodyText: null,
688
690
  size: item.size ?? null,
@@ -816,6 +818,8 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
816
818
  await store.mail.setMailboxes(accountId, [bulkBox], bulk, startedAt);
817
819
  if (!snapshot)
818
820
  await store.sync.setSyncState(key, "mail_folder_scope", scope);
821
+ if (!snapshot)
822
+ await store.mail.backfillSentAt(accountId, SENT_AT_BACKFILL_PER_RUN);
819
823
  await stopIfAborted();
820
824
  const indexed = await index?.(store, key);
821
825
  const coverage = {
@@ -12,6 +12,8 @@ export interface FakeMail {
12
12
  from: FakePerson;
13
13
  to: FakePerson[];
14
14
  cc?: FakePerson[];
15
+ /** The Date header, when it differs from the server's internal date `receivedAt`. */
16
+ date?: string;
15
17
  text?: string;
16
18
  html?: string;
17
19
  listId?: string;
@@ -31,7 +31,7 @@ export const rawOf = (m) => {
31
31
  ...(m.listId ? [`List-Id: News <${m.listId}>`, `List-Unsubscribe: <mailto:leave@${m.listId}>`] : []),
32
32
  `Subject: ${m.subject}`,
33
33
  `Message-ID: <${m.messageId}@example.test>`,
34
- `Date: ${new Date(m.receivedAt).toUTCString()}`,
34
+ `Date: ${m.date ?? new Date(m.receivedAt).toUTCString()}`,
35
35
  "MIME-Version: 1.0",
36
36
  ...(m.extraHeaders ?? []),
37
37
  ];
@@ -0,0 +1,15 @@
1
+ import { type MailTurn } from "../mail/owed.js";
2
+ import { type AccountKey, type ChatActivity, type MessageStore, queryFilter } from "../store/store.js";
3
+ import type { ChatCoverage } from "./chat-coverage.js";
4
+ export interface TurnActivity extends ChatActivity {
5
+ turn: MailTurn;
6
+ }
7
+ /**
8
+ * `tasks agenda` over an email account: each thread with mail in the window, its newest email deciding whose turn it
9
+ * is (`mailTurn`). `storedAt` is when sync last proved the mailbox, so a quiet day with no mail is not `stale`.
10
+ */
11
+ export declare const mailActivity: (store: MessageStore, account: AccountKey, since: number, chats: Parameters<typeof queryFilter>[0] | undefined) => Promise<{
12
+ chats: TurnActivity[];
13
+ storedAt: string | null;
14
+ coverage: (ids: string[]) => ChatCoverage[];
15
+ }>;
@@ -0,0 +1,66 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { mailTurn } from "../mail/owed.js";
3
+ import { MAIL_AGENDA_COLUMNS, queryFilter, } from "../store/store.js";
4
+ const absent = (error) => {
5
+ if (error instanceof CliError && error.code === "not_found")
6
+ return undefined;
7
+ throw error;
8
+ };
9
+ /**
10
+ * `tasks agenda` over an email account: each thread with mail in the window, its newest email deciding whose turn it
11
+ * is (`mailTurn`). `storedAt` is when sync last proved the mailbox, so a quiet day with no mail is not `stale`.
12
+ */
13
+ export const mailActivity = async (store, account, since, chats) => {
14
+ const stored = await store.accounts.storedAccount(account).catch(absent);
15
+ if (!stored)
16
+ return { chats: [], storedAt: null, coverage: () => [] };
17
+ const where = chats
18
+ ? queryFilter(chats, MAIL_AGENDA_COLUMNS, { command: "tasks agenda" })
19
+ : undefined;
20
+ const threads = await store.mail.threadActivity(stored.id, since, where);
21
+ const synced = await store.sync.syncState(account, "mail_coverage");
22
+ const window = (() => {
23
+ try {
24
+ return JSON.parse(synced?.value ?? "{}");
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ })();
30
+ const byId = new Map(threads.map((thread) => [thread.threadId, thread]));
31
+ return {
32
+ chats: threads.map((thread) => ({
33
+ chatId: thread.threadId,
34
+ title: thread.subject,
35
+ kind: "dialog",
36
+ lastMessageAt: new Date(thread.newest?.at ?? thread.lastEmailAt).toISOString(),
37
+ lastMessageId: thread.newest?.externalId ?? null,
38
+ lastFromOwner: thread.newest ? thread.newest.outgoing : null,
39
+ turn: mailTurn(thread.newest),
40
+ })),
41
+ storedAt: synced?.at ?? null,
42
+ coverage: (ids) => ids.map((chatId) => {
43
+ const thread = byId.get(chatId);
44
+ const newest = thread?.newest?.at ?? thread?.lastEmailAt;
45
+ const until = window.until === undefined ? Number.NaN : Date.parse(window.until);
46
+ return {
47
+ chatId,
48
+ title: thread?.subject ?? null,
49
+ completeness: {
50
+ chatId,
51
+ // Sync holds a window of the mailbox, never a thread's whole history.
52
+ state: synced ? "partial" : "unknown",
53
+ upToDate: window.state === "window" && newest !== undefined && !Number.isNaN(until) ? newest <= until : null,
54
+ gaps: false,
55
+ reachesStart: false,
56
+ fetchedAt: synced?.at ?? null,
57
+ },
58
+ held: {
59
+ messages: thread?.emails ?? 0,
60
+ oldestAt: null,
61
+ newestAt: newest === undefined ? null : new Date(newest).toISOString(),
62
+ },
63
+ };
64
+ }),
65
+ };
66
+ };
@@ -79,6 +79,10 @@ export interface Agenda {
79
79
  export interface AgendaService {
80
80
  show(options?: AgendaOptions): Promise<Agenda>;
81
81
  }
82
+ /**
83
+ * An email account's chats are its threads, and whose turn one is follows `mailTurn` (`../mail/owed.ts`), not merely
84
+ * who wrote last: mail sent to many is never owed.
85
+ */
82
86
  export declare const agendaService: (deps: ServiceDeps, now?: () => number) => AgendaService;
83
87
  export type AgendaItem = v.InferOutput<typeof agenda>;
84
88
  /** The agenda in entity keys, as `--json` and MCP answer it; chats become `chat:` references. */