@wirecat/cli-core 0.554.0 → 0.556.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.
- package/CHANGELOG.md +58 -0
- package/dist/calendar/mcp/index.d.ts +12 -12
- package/dist/cli/config-command.js +64 -2
- package/dist/cli/config-issues.d.ts +2 -0
- package/dist/cli/config-issues.js +11 -3
- package/dist/cli/config-repair.d.ts +26 -0
- package/dist/cli/config-repair.js +252 -0
- package/dist/cli/messenger/catch-up-answers.d.ts +50 -1
- package/dist/cli/messenger/catch-up-answers.js +2 -1
- package/dist/cli/messenger/catch-up.d.ts +6 -2
- package/dist/cli/messenger/catch-up.js +11 -2
- package/dist/cli/messenger/conversation-answers.d.ts +3 -0
- package/dist/cli/messenger/inbox.js +1 -1
- package/dist/cli/messenger/message-answers.d.ts +3 -0
- package/dist/cli/messenger/message-writes.d.ts +6 -0
- package/dist/cli/messenger/points.d.ts +9 -4
- package/dist/cli/messenger/points.js +10 -4
- package/dist/cli/messenger/review.js +1 -1
- package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
- package/dist/cli/messenger/tasks-analysis-command.d.ts +8 -0
- package/dist/cli/messenger/tasks-analysis-command.js +184 -0
- package/dist/cli/messenger/tasks-command.js +11 -2
- package/dist/cli/steps.js +10 -0
- package/dist/domain/models.d.ts +4 -0
- package/dist/entities/contact-context.d.ts +6 -0
- package/dist/entities/conversation-results.d.ts +6 -0
- package/dist/entities/evidence.d.ts +24 -0
- package/dist/entities/index.d.ts +327 -5
- package/dist/entities/index.js +6 -2
- package/dist/entities/listener.d.ts +12 -0
- package/dist/entities/message-context.d.ts +9 -0
- package/dist/entities/message-write.d.ts +12 -0
- package/dist/entities/message.d.ts +6 -0
- package/dist/entities/message.js +6 -0
- package/dist/entities/store-records.d.ts +3 -0
- package/dist/entities/task-results.d.ts +72 -0
- package/dist/entities/task-results.js +40 -1
- package/dist/errors/reasons.d.ts +1 -1
- package/dist/errors/reasons.js +9 -0
- package/dist/mail/cli/index.d.ts +3 -1
- package/dist/mail/cli/index.js +7 -1
- package/dist/mail/cli/marks.d.ts +26 -0
- package/dist/mail/cli/marks.js +51 -0
- package/dist/mail/cli/review.d.ts +24 -0
- package/dist/mail/cli/review.js +274 -0
- package/dist/mail/index.d.ts +3 -0
- package/dist/mail/index.js +3 -0
- package/dist/mail/marks.d.ts +56 -0
- package/dist/mail/marks.js +178 -0
- package/dist/mail/owed.d.ts +11 -0
- package/dist/mail/owed.js +14 -0
- package/dist/mail/review.d.ts +37 -0
- package/dist/mail/review.js +118 -0
- package/dist/sends/permissions.js +1 -0
- package/dist/services/agenda-mail.d.ts +15 -0
- package/dist/services/agenda-mail.js +66 -0
- package/dist/services/agenda.d.ts +4 -0
- package/dist/services/agenda.js +17 -7
- package/dist/services/task-analysis.d.ts +58 -0
- package/dist/services/task-analysis.js +125 -0
- package/dist/store/exports.d.ts +2 -1
- package/dist/store/exports.js +1 -0
- package/dist/store/index.d.ts +1 -1
- package/dist/store/sqlite/email-activity.d.ts +93 -0
- package/dist/store/sqlite/email-activity.js +109 -0
- package/dist/store/sqlite/emails.d.ts +9 -3
- package/dist/store/sqlite/emails.js +5 -8
- package/dist/store/sqlite/task-analysis-marks.d.ts +7 -0
- package/dist/store/sqlite/task-analysis-marks.js +27 -0
- package/dist/store/sync.d.ts +5 -1
- package/dist/store/sync.js +4 -0
- package/dist/store/types.d.ts +25 -0
- package/package.json +1 -1
- package/parity.json +29 -0
- package/skills/follow-ups/SKILL.md +22 -2
|
@@ -0,0 +1,274 @@
|
|
|
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 { readPastMark } from "../marks.js";
|
|
20
|
+
import { hitText } from "../read.js";
|
|
21
|
+
import { OWED_DAYS, REVIEW_THREADS, reviewMail } from "../review.js";
|
|
22
|
+
const DAY = 86_400_000;
|
|
23
|
+
/** Where a mail review starts: a time only; one thread is `conversations show`. */
|
|
24
|
+
export const MAIL_REVIEW_QUERY = defineQuery({
|
|
25
|
+
entity: "message",
|
|
26
|
+
backend: "native",
|
|
27
|
+
fields: [
|
|
28
|
+
nativeField({ name: "date", type: "time", operators: ["range"], example: "date>2h" }),
|
|
29
|
+
nativeField({ name: "bulk", type: "kw", operators: ["term"], example: "bulk:false" }),
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
/** The mail review's steps; a host lists them beside its own so its step checks know them. */
|
|
33
|
+
export const MAIL_REVIEW_STEPS = defineSteps([
|
|
34
|
+
{
|
|
35
|
+
id: "mail-messages.acknowledge",
|
|
36
|
+
command: "messages acknowledge",
|
|
37
|
+
args: [
|
|
38
|
+
{ name: "holder", required: true },
|
|
39
|
+
{ name: "cursor", required: true },
|
|
40
|
+
],
|
|
41
|
+
why: "once these are processed, this moves the holder's mark past them",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "mail-review.next",
|
|
45
|
+
command: "review",
|
|
46
|
+
args: [{ name: "query", required: true }],
|
|
47
|
+
why: "the next review starts where this one ended",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "mail-conversations.show",
|
|
51
|
+
command: "conversations show",
|
|
52
|
+
args: [{ name: "conversation", required: true }],
|
|
53
|
+
why: "only the newest emails of the thread were read here; this reads it whole",
|
|
54
|
+
},
|
|
55
|
+
]);
|
|
56
|
+
const step = (id) => MAIL_REVIEW_STEPS.find((one) => one.id === id);
|
|
57
|
+
const waitingHours = (value) => value === true ? AGENDA_DEFAULTS.waiting / 3_600_000 : parseDuration(String(value), "--waiting") / 3_600_000;
|
|
58
|
+
const quoted = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
59
|
+
/** A file name for the mailbox's points: they describe the mailbox, whichever profile reads it. */
|
|
60
|
+
const pointsName = (mailbox) => encodeURIComponent(mailbox.account);
|
|
61
|
+
const markText = (marks) => {
|
|
62
|
+
const said = [
|
|
63
|
+
marks?.bulk ? "bulk" : "",
|
|
64
|
+
marks?.listId ? `list ${marks.listId}` : "",
|
|
65
|
+
marks?.ruleId ? `rule ${marks.ruleId}` : "",
|
|
66
|
+
];
|
|
67
|
+
const shown = said.filter(Boolean).join(" · ");
|
|
68
|
+
return shown ? `\n [${shown}]` : "";
|
|
69
|
+
};
|
|
70
|
+
const reviewAnswer = (found, mailbox, fresh) => {
|
|
71
|
+
const read = found.chats
|
|
72
|
+
.flatMap((chat) => chat.messages.map((message) => ({ ...message, chatTitle: chat.title })))
|
|
73
|
+
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
74
|
+
const cut = found.chats.filter((chat) => chat.more);
|
|
75
|
+
const ref = (thread) => formatReference({ type: "chat", ...mailbox, chat: thread });
|
|
76
|
+
const turns = found.unanswered !== undefined || found.waiting !== undefined;
|
|
77
|
+
const missing = [
|
|
78
|
+
...(cut.length > 0
|
|
79
|
+
? [
|
|
80
|
+
{
|
|
81
|
+
field: "items",
|
|
82
|
+
why: `only the newest emails of ${cut.length} thread(s)`,
|
|
83
|
+
step: "mail-conversations.show",
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
: []),
|
|
87
|
+
...(found.skipped.length > 0
|
|
88
|
+
? [
|
|
89
|
+
{
|
|
90
|
+
field: "items",
|
|
91
|
+
why: `skipped ${found.skipped.length} threads — more than ${REVIEW_THREADS} at once; the next review reads them`,
|
|
92
|
+
},
|
|
93
|
+
]
|
|
94
|
+
: []),
|
|
95
|
+
...(found.quiet > 0
|
|
96
|
+
? [{ field: "items", why: `bulk:false left out ${found.quiet} threads of mail sent to many, lists or envelopes` }]
|
|
97
|
+
: []),
|
|
98
|
+
];
|
|
99
|
+
const next = [
|
|
100
|
+
...(!fresh && !turns
|
|
101
|
+
? [
|
|
102
|
+
stepOf(step("mail-review.next"), {
|
|
103
|
+
args: { query: `date>${quoted(found.complete ? found.until : found.since)}` },
|
|
104
|
+
}),
|
|
105
|
+
]
|
|
106
|
+
: []),
|
|
107
|
+
...cut.map((chat) => stepOf(step("mail-conversations.show"), { args: { conversation: ref(chat.id) } })),
|
|
108
|
+
];
|
|
109
|
+
return listOf("review", {
|
|
110
|
+
items: read.map((message) => ({
|
|
111
|
+
...summaryOf(message, mailbox),
|
|
112
|
+
email: {
|
|
113
|
+
...messageItem(message, mailbox).email,
|
|
114
|
+
bulk: message.marks?.bulk ?? false,
|
|
115
|
+
list_id: message.marks?.listId ?? null,
|
|
116
|
+
rule_id: message.marks?.ruleId ?? null,
|
|
117
|
+
},
|
|
118
|
+
})),
|
|
119
|
+
limit: Math.max(1, read.length),
|
|
120
|
+
has_more: false,
|
|
121
|
+
next,
|
|
122
|
+
missing,
|
|
123
|
+
meta: found.complete ? {} : { complete: false },
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
const holderReview = async (command, mail, holder, query) => {
|
|
127
|
+
const { unanswered, waiting } = command.opts();
|
|
128
|
+
if (typedQuery(query).trim() || unanswered !== undefined || waiting !== undefined)
|
|
129
|
+
throw new CliError("validation_error", "--holder reads past its own mark — not with a query, --unanswered or --waiting");
|
|
130
|
+
const { read, mailbox, marks } = await mail.read(command, async (store, mailbox) => {
|
|
131
|
+
const read = await readPastMark(store, mailbox, holder);
|
|
132
|
+
const account = read.emails.length ? await store.accounts.storedAccount(mailbox) : undefined;
|
|
133
|
+
const marks = account
|
|
134
|
+
? await store.mail.emailMarks(account.id, read.emails.map((one) => one.id))
|
|
135
|
+
: new Map();
|
|
136
|
+
return { read, mailbox, marks };
|
|
137
|
+
});
|
|
138
|
+
const chats = new Map();
|
|
139
|
+
for (const email of read.emails) {
|
|
140
|
+
const chat = chats.get(email.chatId) ?? {
|
|
141
|
+
id: email.chatId,
|
|
142
|
+
title: email.chatTitle,
|
|
143
|
+
kind: "dialog",
|
|
144
|
+
messages: [],
|
|
145
|
+
more: false,
|
|
146
|
+
};
|
|
147
|
+
const mark = marks.get(email.id);
|
|
148
|
+
chat.messages.push(mark ? { ...email, marks: mark } : email);
|
|
149
|
+
chats.set(email.chatId, chat);
|
|
150
|
+
}
|
|
151
|
+
const now = new Date().toISOString();
|
|
152
|
+
const found = {
|
|
153
|
+
since: read.mark?.throughAt ? new Date(read.mark.throughAt).toISOString() : now,
|
|
154
|
+
until: now,
|
|
155
|
+
complete: !read.more,
|
|
156
|
+
chats: [...chats.values()],
|
|
157
|
+
skipped: [],
|
|
158
|
+
partial: false,
|
|
159
|
+
quiet: 0,
|
|
160
|
+
};
|
|
161
|
+
const answer = reviewAnswer(found, mailbox, true);
|
|
162
|
+
const skipped = Object.entries(read.skipped).filter(([, count]) => count > 0);
|
|
163
|
+
answer.next = [
|
|
164
|
+
stepOf(step("mail-messages.acknowledge"), { args: { holder, cursor: read.cursor } }),
|
|
165
|
+
...(answer.next ?? []),
|
|
166
|
+
];
|
|
167
|
+
answer.missing = [
|
|
168
|
+
...(answer.missing ?? []),
|
|
169
|
+
...(read.held > 0 ? [{ field: "items", why: `${read.held} partial emails held until they are whole` }] : []),
|
|
170
|
+
...(skipped.length
|
|
171
|
+
? [{ field: "items", why: `left out: ${skipped.map(([reason, count]) => `${count} ${reason}`).join(", ")}` }]
|
|
172
|
+
: []),
|
|
173
|
+
];
|
|
174
|
+
const output = outputFor(command);
|
|
175
|
+
renderAnswer(command, output, answer, () => {
|
|
176
|
+
output.renderer.note(read.mark
|
|
177
|
+
? `${holder}: past ${read.mark.throughId}; acknowledge with --cursor ${read.cursor}${read.more ? "; more after these" : ""}`
|
|
178
|
+
: `${holder} has no mark yet: it starts from now — acknowledge with --cursor ${read.cursor}`);
|
|
179
|
+
return read.emails.map((hit) => `${hitText(hit)}${markText(marks.get(hit.id))}\n`).join("");
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* `review` over the stored mail of the profile's mailbox, with tg's words: what is new, what the owner owes, what
|
|
184
|
+
* waits for an answer. It reads the store only and opens no task.
|
|
185
|
+
*/
|
|
186
|
+
export const mailReviewCommand = (mail) => annotate(new Command("review"), {
|
|
187
|
+
examples: [
|
|
188
|
+
`${mail.app.command} personal review "date>2h"`,
|
|
189
|
+
`${mail.app.command} personal review --new`,
|
|
190
|
+
`${mail.app.command} personal review --unanswered 1d`,
|
|
191
|
+
`${mail.app.command} personal review --waiting 7d`,
|
|
192
|
+
],
|
|
193
|
+
effects: LOCAL_READ,
|
|
194
|
+
partial: true,
|
|
195
|
+
output: { entity: "message", view: "summary" },
|
|
196
|
+
query: MAIL_REVIEW_QUERY,
|
|
197
|
+
})
|
|
198
|
+
.summary("threads with new mail, mail you owe an answer, mail waiting for one")
|
|
199
|
+
.description("every stored email, yours too, in threads that changed since a point — for reviewing who owes what. " +
|
|
200
|
+
"It reads what sync stored, so it asks the mail server nothing")
|
|
201
|
+
.addHelpText("after", queryHelp(MAIL_REVIEW_QUERY))
|
|
202
|
+
.argument("[query...]", `where the last review ended: date>2h; ${REVIEW_DAYS} days ago if no date`)
|
|
203
|
+
.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")
|
|
204
|
+
.option("--waiting [duration]", "only threads where you wrote last and nobody answered for this long — 3d; 7d if not given")
|
|
205
|
+
.option("--holder <name>", "read for this holder — an agent or a pipeline — what is stored past its processing mark, by store order; " +
|
|
206
|
+
"bulk, envelopes, drafts and partial mail left out; `messages acknowledge` moves the mark")
|
|
207
|
+
.option("--new", "what changed since the last `review --new` of this mailbox, a point per thread — for scheduled runs")
|
|
208
|
+
.addHelpText("after", `
|
|
209
|
+
Without a date, --unanswered and --waiting look at threads active in the last ${OWED_DAYS} days.
|
|
210
|
+
Every email is shown, mail sent to many too; each item's email says bulk, list_id and rule_id, and its sender_score.
|
|
211
|
+
bulk:false leaves out threads whose newest email is mail sent to many, a list's or recorded by an envelope rule.
|
|
212
|
+
Mail newer than the last finished sync waits for the next review. Each reader keeps its own --new point: you,
|
|
213
|
+
an agent over MCP, and each scheduled run.`)
|
|
214
|
+
.action(async function (query) {
|
|
215
|
+
const options = this.opts();
|
|
216
|
+
if (options.holder !== undefined)
|
|
217
|
+
return holderReview(this, mail, options.holder, query);
|
|
218
|
+
const { since: from, meta } = catchUpQuery(query, MAIL_REVIEW_QUERY, "review");
|
|
219
|
+
const bulk = nativeTerms(listQuery(typedQuery(query), MAIL_REVIEW_QUERY, "review"), "review").get("bulk")?.value;
|
|
220
|
+
if (bulk !== undefined && bulk !== "false")
|
|
221
|
+
throw new CliError("validation_error", `bulk: takes false — leave it out to see all mail, not "${bulk}"`);
|
|
222
|
+
const turns = options.unanswered !== undefined || options.waiting !== undefined;
|
|
223
|
+
if (options.new && (from !== undefined || turns))
|
|
224
|
+
throw new CliError("validation_error", "--new keeps its own point — not with a date, --unanswered or --waiting");
|
|
225
|
+
const unanswered = options.unanswered === undefined ? undefined : unansweredHours(options.unanswered);
|
|
226
|
+
const waiting = options.waiting === undefined ? undefined : waitingHours(options.waiting);
|
|
227
|
+
const env = environmentOf(this).env ?? process.env;
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
const { found, mailbox, saved } = await mail.read(this, async (store, mailbox) => {
|
|
230
|
+
const saved = options.new
|
|
231
|
+
? checkPoints(mail.app, {
|
|
232
|
+
command: pointsOf(this, "review", mail.app),
|
|
233
|
+
profile: pointsName(mailbox),
|
|
234
|
+
env,
|
|
235
|
+
firstLookMs: REVIEW_DAYS * DAY,
|
|
236
|
+
})
|
|
237
|
+
: undefined;
|
|
238
|
+
const since = from ?? saved?.first ?? (turns ? now - OWED_DAYS * DAY : reviewStart(now));
|
|
239
|
+
const found = await reviewMail(store, mailbox, {
|
|
240
|
+
since,
|
|
241
|
+
now,
|
|
242
|
+
...(saved ? { points: saved.chats } : {}),
|
|
243
|
+
...(bulk === "false" ? { noBulk: true } : {}),
|
|
244
|
+
...(unanswered === undefined ? {} : { unansweredAfterHours: unanswered }),
|
|
245
|
+
...(waiting === undefined ? {} : { waitingAfterHours: waiting }),
|
|
246
|
+
});
|
|
247
|
+
return { found, mailbox, saved };
|
|
248
|
+
});
|
|
249
|
+
const output = outputFor(this);
|
|
250
|
+
const answer = reviewAnswer(found, mailbox, saved !== undefined);
|
|
251
|
+
if (meta)
|
|
252
|
+
answer.meta = { ...meta, ...answer.meta };
|
|
253
|
+
renderAnswer(this, output, answer, () => {
|
|
254
|
+
const read = found.chats.flatMap((chat) => chat.messages);
|
|
255
|
+
if (found.unanswered || found.waiting)
|
|
256
|
+
output.renderer.note(`${[
|
|
257
|
+
found.unanswered ? `owed for ${found.unanswered.olderThanHours}h or more` : "",
|
|
258
|
+
found.waiting ? `waiting ${found.waiting.olderThanHours}h or more` : "",
|
|
259
|
+
]
|
|
260
|
+
.filter(Boolean)
|
|
261
|
+
.join(", ")}, threads active from ${found.since}`);
|
|
262
|
+
else
|
|
263
|
+
output.renderer.note(`from ${found.since} to ${found.until}${found.complete ? "" : ", incomplete"}`);
|
|
264
|
+
if (found.quiet > 0)
|
|
265
|
+
output.renderer.note(`bulk:false left out ${found.quiet} threads`);
|
|
266
|
+
if (read.length === 0)
|
|
267
|
+
output.renderer.note("nothing new in the stored mail");
|
|
268
|
+
return read
|
|
269
|
+
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))
|
|
270
|
+
.map((hit) => `${hitText(hit)}${markText(hit.marks)}\n`)
|
|
271
|
+
.join("");
|
|
272
|
+
});
|
|
273
|
+
saved?.save(found.checked ?? {}, found.complete ? { through: found.until } : {});
|
|
274
|
+
});
|
package/dist/mail/index.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ 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 { acknowledgeMark, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, type MailMark, type MarkRead, markOf, marksOf, readPastMark, type SkipReason, } from "./marks.js";
|
|
10
|
+
export { type MailTurn, mailTurn } from "./owed.js";
|
|
9
11
|
export { type Address, htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, type ParsedMail, parseMail, } from "./parse.js";
|
|
10
12
|
export { PARTIAL, type PartialOutcome } from "./partial.js";
|
|
11
13
|
export { PRESET_NAMES, PRESETS, type PresetName, presetMatches } from "./presets.js";
|
|
@@ -13,6 +15,7 @@ export { domainsToAge, groupKey, type Probe, type ProbeGroup, probeMail, SUGGEST
|
|
|
13
15
|
export { type MailFolderProgress, mailProgress } from "./progress.js";
|
|
14
16
|
export { ATTACHMENT_CHARS, attachmentsText, hitText, lowerSuspicious, type MailSearch, type ShownMail, searchMail, showMail, withAttachmentText, } from "./read.js";
|
|
15
17
|
export { type FileStamp, FULL_PASS_MS, MBOX_RESUME, type MboxPoint, mergeRanges, parsePoint, type Ranges, type ResumePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
|
|
18
|
+
export { isBulk, type MailReview, type MailReviewOptions, OWED_DAYS, REVIEW_THREADS, reviewMail } from "./review.js";
|
|
16
19
|
export { ruleInput, STORE_ACTION } from "./rule-input.js";
|
|
17
20
|
export { type MailRuleSet, storedMailRule, storedMailRuleSet, storedMailRules } from "./rule-stored.js";
|
|
18
21
|
export { mapProposedTags, ruleTagger, settleRuleTags, type TagOutcome } from "./rule-tags.js";
|
package/dist/mail/index.js
CHANGED
|
@@ -6,6 +6,8 @@ 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 { acknowledgeMark, cursorOf, HOLD_PARTIAL_MS, HOLDER, MARK_BATCH, markOf, marksOf, readPastMark, } from "./marks.js";
|
|
10
|
+
export { mailTurn } from "./owed.js";
|
|
9
11
|
export { htmlToText, looksLikeHtml, MAX_ATTACHMENTS, MAX_BODY_CHARS, parseMail, } from "./parse.js";
|
|
10
12
|
export { PARTIAL } from "./partial.js";
|
|
11
13
|
export { PRESET_NAMES, PRESETS, presetMatches } from "./presets.js";
|
|
@@ -13,6 +15,7 @@ export { domainsToAge, groupKey, probeMail, SUGGEST_RULE } from "./probe.js";
|
|
|
13
15
|
export { mailProgress } from "./progress.js";
|
|
14
16
|
export { ATTACHMENT_CHARS, attachmentsText, hitText, lowerSuspicious, searchMail, showMail, withAttachmentText, } from "./read.js";
|
|
15
17
|
export { FULL_PASS_MS, MBOX_RESUME, mergeRanges, parsePoint, rangesOf, resumeKey, rulesKey, within, } from "./resume.js";
|
|
18
|
+
export { isBulk, OWED_DAYS, REVIEW_THREADS, reviewMail } from "./review.js";
|
|
16
19
|
export { ruleInput, STORE_ACTION } from "./rule-input.js";
|
|
17
20
|
export { storedMailRule, storedMailRuleSet, storedMailRules } from "./rule-stored.js";
|
|
18
21
|
export { mapProposedTags, ruleTagger, settleRuleTags } from "./rule-tags.js";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AccountKey, MessageStore, StoredHit } from "../store/store.js";
|
|
2
|
+
/** A holder's name: what `--holder` takes and the mark's key ends with. */
|
|
3
|
+
export declare const HOLDER: RegExp;
|
|
4
|
+
/** At most this many emails are read past the mark in one go; the rest come with the next read. */
|
|
5
|
+
export declare const MARK_BATCH = 200;
|
|
6
|
+
/** A partial email waits this long to be stored whole, then is skipped. */
|
|
7
|
+
export declare const HOLD_PARTIAL_MS: number;
|
|
8
|
+
export type SkipReason = "bulk" | "envelope" | "partial" | "draft";
|
|
9
|
+
/**
|
|
10
|
+
* How far one holder — an agent, a pipeline — has processed an account's mail, in the shared store (`sync_cursors`,
|
|
11
|
+
* key `consumer:<holder>`). It counts by the store's insert order (`emails.id`), so mail stored late, older than what
|
|
12
|
+
* was read, is still after the mark. Partial emails are held until they are whole.
|
|
13
|
+
*/
|
|
14
|
+
export interface MailMark {
|
|
15
|
+
holder: string;
|
|
16
|
+
throughId: number;
|
|
17
|
+
/** ms: when the email at `throughId` was received; for display. */
|
|
18
|
+
throughAt: number | null;
|
|
19
|
+
held: number[];
|
|
20
|
+
read: number;
|
|
21
|
+
skipped: Record<SkipReason, number>;
|
|
22
|
+
/** ms. */
|
|
23
|
+
updatedAt: number;
|
|
24
|
+
}
|
|
25
|
+
export declare const markOf: (store: MessageStore, mailbox: AccountKey, holder: string) => Promise<MailMark | undefined>;
|
|
26
|
+
/** Every holder's mark on the mailbox, by name. */
|
|
27
|
+
export declare const marksOf: (store: MessageStore, mailbox: AccountKey) => Promise<MailMark[]>;
|
|
28
|
+
/** The mark's state, as a cursor `acknowledge` takes back: the last id read, and when it was read. */
|
|
29
|
+
export declare const cursorOf: (throughId: number, readAt: number) => string;
|
|
30
|
+
export interface MarkRead {
|
|
31
|
+
/** The emails for the holder, oldest first; bulk, envelopes, drafts and partial mail left out. */
|
|
32
|
+
emails: StoredHit[];
|
|
33
|
+
/** What `acknowledge --cursor` takes once the holder is done with these. */
|
|
34
|
+
cursor: string;
|
|
35
|
+
/** More is stored after this batch: read again after acknowledging. */
|
|
36
|
+
more: boolean;
|
|
37
|
+
held: number;
|
|
38
|
+
skipped: Record<SkipReason, number>;
|
|
39
|
+
/** `undefined` before the holder's first acknowledge: it starts from the mail stored now. */
|
|
40
|
+
mark: MailMark | undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* What the holder has not processed: the emails stored after its mark, and the held ones that are whole now. Reads
|
|
44
|
+
* only; the mark moves when the holder acknowledges. A holder with no mark starts from now: its first read is
|
|
45
|
+
* empty and gives the cursor of the newest stored email.
|
|
46
|
+
*/
|
|
47
|
+
export declare const readPastMark: (store: MessageStore, mailbox: AccountKey, holder: string, { now, limit }?: {
|
|
48
|
+
now?: number;
|
|
49
|
+
limit?: number;
|
|
50
|
+
}) => Promise<MarkRead>;
|
|
51
|
+
/**
|
|
52
|
+
* Moves the holder's mark to the cursor its read gave, never back. An email whole when it was read counts read; one
|
|
53
|
+
* still partial, or changed after the read (it may have become whole since), is held and comes with the next read.
|
|
54
|
+
* The same cursor twice changes nothing more.
|
|
55
|
+
*/
|
|
56
|
+
export declare const acknowledgeMark: (store: MessageStore, mailbox: AccountKey, holder: string, cursor: string, now?: number) => Promise<MailMark>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
/** A holder's name: what `--holder` takes and the mark's key ends with. */
|
|
3
|
+
export const HOLDER = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
4
|
+
/** At most this many emails are read past the mark in one go; the rest come with the next read. */
|
|
5
|
+
export const MARK_BATCH = 200;
|
|
6
|
+
/** A partial email waits this long to be stored whole, then is skipped. */
|
|
7
|
+
export const HOLD_PARTIAL_MS = 7 * 86_400_000;
|
|
8
|
+
/** Held emails kept on one mark at most; past it the oldest are skipped as partial. */
|
|
9
|
+
const MOST_HELD = 500;
|
|
10
|
+
const PREFIX = "consumer:";
|
|
11
|
+
const noneSkipped = () => ({ bulk: 0, envelope: 0, partial: 0, draft: 0 });
|
|
12
|
+
const keyOf = (holder) => {
|
|
13
|
+
if (!HOLDER.test(holder))
|
|
14
|
+
throw new CliError("validation_error", `a holder is lower-case letters, digits and dashes, not "${holder}"`);
|
|
15
|
+
return `${PREFIX}${holder}`;
|
|
16
|
+
};
|
|
17
|
+
const parsed = (holder, value, at) => {
|
|
18
|
+
try {
|
|
19
|
+
const saved = JSON.parse(value);
|
|
20
|
+
if (typeof saved.throughId !== "number")
|
|
21
|
+
return undefined;
|
|
22
|
+
return {
|
|
23
|
+
holder,
|
|
24
|
+
throughId: saved.throughId,
|
|
25
|
+
throughAt: saved.throughAt ?? null,
|
|
26
|
+
held: Array.isArray(saved.held) ? saved.held.filter((id) => typeof id === "number") : [],
|
|
27
|
+
read: saved.read ?? 0,
|
|
28
|
+
skipped: { ...noneSkipped(), ...saved.skipped },
|
|
29
|
+
updatedAt: at,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const accountOf = async (store, mailbox) => store.accounts.storedAccount(mailbox).catch((error) => {
|
|
37
|
+
if (error instanceof CliError && error.code === "not_found")
|
|
38
|
+
return undefined;
|
|
39
|
+
throw error;
|
|
40
|
+
});
|
|
41
|
+
export const markOf = async (store, mailbox, holder) => {
|
|
42
|
+
const saved = await store.sync.syncState(mailbox, keyOf(holder));
|
|
43
|
+
return saved ? parsed(holder, saved.value, Date.parse(saved.at)) : undefined;
|
|
44
|
+
};
|
|
45
|
+
/** Every holder's mark on the mailbox, by name. */
|
|
46
|
+
export const marksOf = async (store, mailbox) => {
|
|
47
|
+
const account = await accountOf(store, mailbox);
|
|
48
|
+
if (!account)
|
|
49
|
+
return [];
|
|
50
|
+
return (await store.mail.statesLike(account.id, PREFIX))
|
|
51
|
+
.flatMap(({ key, value, at }) => parsed(key.slice(PREFIX.length), value, at) ?? [])
|
|
52
|
+
.sort((a, b) => a.holder.localeCompare(b.holder));
|
|
53
|
+
};
|
|
54
|
+
/** Why an email is not for the holder, or `held` while it is partial and young, or `read`. */
|
|
55
|
+
const verdict = (email, now) => {
|
|
56
|
+
if (email.draft)
|
|
57
|
+
return "draft";
|
|
58
|
+
if (email.envelope)
|
|
59
|
+
return "envelope";
|
|
60
|
+
if (email.bulk)
|
|
61
|
+
return "bulk";
|
|
62
|
+
if (email.partial)
|
|
63
|
+
return now - email.storedAt < HOLD_PARTIAL_MS ? "held" : "partial";
|
|
64
|
+
return "read";
|
|
65
|
+
};
|
|
66
|
+
/** The mark's state, as a cursor `acknowledge` takes back: the last id read, and when it was read. */
|
|
67
|
+
export const cursorOf = (throughId, readAt) => `${throughId}.${readAt}`;
|
|
68
|
+
const parseCursor = (cursor) => {
|
|
69
|
+
const match = /^(\d+)\.(\d+)$/.exec(cursor.trim());
|
|
70
|
+
if (!match)
|
|
71
|
+
throw new CliError("validation_error", `--cursor takes the cursor review gave, such as 9123.1762090000000`);
|
|
72
|
+
return { throughId: Number(match[1]), readAt: Number(match[2]) };
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* What the holder has not processed: the emails stored after its mark, and the held ones that are whole now. Reads
|
|
76
|
+
* only; the mark moves when the holder acknowledges. A holder with no mark starts from now: its first read is
|
|
77
|
+
* empty and gives the cursor of the newest stored email.
|
|
78
|
+
*/
|
|
79
|
+
export const readPastMark = async (store, mailbox, holder, { now = Date.now(), limit = MARK_BATCH } = {}) => {
|
|
80
|
+
const mark = await markOf(store, mailbox, holder);
|
|
81
|
+
const account = await accountOf(store, mailbox);
|
|
82
|
+
const empty = { emails: [], more: false, held: 0, skipped: noneSkipped(), mark };
|
|
83
|
+
if (!account)
|
|
84
|
+
return { ...empty, cursor: cursorOf(0, now) };
|
|
85
|
+
if (!mark)
|
|
86
|
+
return { ...empty, cursor: cursorOf(await store.mail.newestEmailId(account.id), now) };
|
|
87
|
+
const found = await store.mail.emailsAfter(account.id, mark.throughId, limit + 1, mark.held);
|
|
88
|
+
// Mail a running sync stored is read once that sync ended: it files bulk mail only at its end.
|
|
89
|
+
const settled = await store.sync.syncState(mailbox, "mail_coverage");
|
|
90
|
+
const settledAt = settled ? Date.parse(settled.at) : Number.POSITIVE_INFINITY;
|
|
91
|
+
const after = found.filter((email) => email.id > mark.throughId);
|
|
92
|
+
const unsettled = after.findIndex((email) => email.storedAt > settledAt);
|
|
93
|
+
const fresh = unsettled === -1 ? after : after.slice(0, unsettled);
|
|
94
|
+
const batch = fresh.slice(0, limit);
|
|
95
|
+
const examined = [...found.filter((email) => email.id <= mark.throughId), ...batch];
|
|
96
|
+
const skipped = noneSkipped();
|
|
97
|
+
const wanted = [];
|
|
98
|
+
let held = 0;
|
|
99
|
+
for (const email of examined) {
|
|
100
|
+
const said = verdict(email, now);
|
|
101
|
+
if (said === "read")
|
|
102
|
+
wanted.push(email.id);
|
|
103
|
+
else if (said === "held")
|
|
104
|
+
held += 1;
|
|
105
|
+
else if (email.id > mark.throughId)
|
|
106
|
+
skipped[said] += 1;
|
|
107
|
+
}
|
|
108
|
+
const emails = await store.mail.emailHits(wanted.sort((a, b) => a - b));
|
|
109
|
+
return {
|
|
110
|
+
emails,
|
|
111
|
+
cursor: cursorOf(batch.at(-1)?.id ?? mark.throughId, now),
|
|
112
|
+
more: after.length > batch.length,
|
|
113
|
+
held,
|
|
114
|
+
skipped,
|
|
115
|
+
mark,
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Moves the holder's mark to the cursor its read gave, never back. An email whole when it was read counts read; one
|
|
120
|
+
* still partial, or changed after the read (it may have become whole since), is held and comes with the next read.
|
|
121
|
+
* The same cursor twice changes nothing more.
|
|
122
|
+
*/
|
|
123
|
+
export const acknowledgeMark = async (store, mailbox, holder, cursor, now = Date.now()) => {
|
|
124
|
+
const key = keyOf(holder);
|
|
125
|
+
const { throughId, readAt } = parseCursor(cursor);
|
|
126
|
+
const account = await accountOf(store, mailbox);
|
|
127
|
+
if (!account)
|
|
128
|
+
throw new CliError("not_found", `no mail of ${mailbox.account} is stored`);
|
|
129
|
+
const mark = await markOf(store, mailbox, holder);
|
|
130
|
+
const from = mark?.throughId ?? throughId;
|
|
131
|
+
if (throughId < from)
|
|
132
|
+
throw new CliError("validation_error", `${holder} is already past ${throughId}: its mark is at ${from}`);
|
|
133
|
+
const examined = [];
|
|
134
|
+
let after = from;
|
|
135
|
+
const held = new Set(mark?.held ?? []);
|
|
136
|
+
const again = await store.mail.emailsAfter(account.id, Number.MAX_SAFE_INTEGER, 0, [...held]);
|
|
137
|
+
examined.push(...again);
|
|
138
|
+
while (after < throughId) {
|
|
139
|
+
const page = (await store.mail.emailsAfter(account.id, after, MARK_BATCH)).filter((one) => one.id <= throughId);
|
|
140
|
+
if (page.length === 0)
|
|
141
|
+
break;
|
|
142
|
+
examined.push(...page);
|
|
143
|
+
after = page.at(-1)?.id ?? throughId;
|
|
144
|
+
}
|
|
145
|
+
const skipped = { ...(mark?.skipped ?? noneSkipped()) };
|
|
146
|
+
let read = mark?.read ?? 0;
|
|
147
|
+
const keep = [];
|
|
148
|
+
const live = new Set(again.map((one) => one.id));
|
|
149
|
+
for (const id of held)
|
|
150
|
+
if (!live.has(id))
|
|
151
|
+
held.delete(id);
|
|
152
|
+
for (const email of examined) {
|
|
153
|
+
const said = verdict(email, readAt);
|
|
154
|
+
const wasHeld = held.has(email.id);
|
|
155
|
+
if (said === "held" || (said === "read" && email.changedAt > readAt) || (wasHeld && email.changedAt > readAt)) {
|
|
156
|
+
if (email.partial && now - email.storedAt >= HOLD_PARTIAL_MS)
|
|
157
|
+
skipped.partial += 1;
|
|
158
|
+
else
|
|
159
|
+
keep.push(email.id);
|
|
160
|
+
}
|
|
161
|
+
else if (said === "read")
|
|
162
|
+
read += wasHeld || email.id > from ? 1 : 0;
|
|
163
|
+
else if (!wasHeld || said === "partial")
|
|
164
|
+
skipped[said] += 1;
|
|
165
|
+
}
|
|
166
|
+
const kept = [...new Set(keep)].sort((a, b) => a - b);
|
|
167
|
+
skipped.partial += Math.max(0, kept.length - MOST_HELD);
|
|
168
|
+
const through = examined.filter((one) => one.id <= throughId).at(-1);
|
|
169
|
+
const next = {
|
|
170
|
+
throughId,
|
|
171
|
+
throughAt: through?.id === throughId ? through.at : (mark?.throughAt ?? null),
|
|
172
|
+
held: kept.slice(-MOST_HELD),
|
|
173
|
+
read,
|
|
174
|
+
skipped,
|
|
175
|
+
};
|
|
176
|
+
await store.sync.setSyncState(mailbox, key, JSON.stringify(next));
|
|
177
|
+
return { holder, ...next, updatedAt: now };
|
|
178
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -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>;
|