@better_openclaw/betteremail 0.1.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/openclaw.plugin.json +53 -0
- package/package.json +16 -0
- package/src/classifier.ts +158 -0
- package/src/commands/emails.ts +61 -0
- package/src/digest.ts +108 -0
- package/src/email-log.ts +53 -0
- package/src/index.ts +146 -0
- package/src/pipeline.ts +179 -0
- package/src/poller.ts +189 -0
- package/src/scheduler.ts +65 -0
- package/src/tools/defer-email.ts +32 -0
- package/src/tools/dismiss-email.ts +26 -0
- package/src/tools/get-email-digest.ts +74 -0
- package/src/tools/mark-email-handled.ts +25 -0
- package/src/trimmer.ts +54 -0
- package/src/types.ts +117 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "betteremail",
|
|
3
|
+
"name": "BetterEmail Digest",
|
|
4
|
+
"description": "Intelligent email digest — polls Gmail, deduplicates, classifies importance, exposes digest to agent",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"configSchema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"accounts": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"items": { "type": "string" },
|
|
13
|
+
"default": []
|
|
14
|
+
},
|
|
15
|
+
"pollIntervalMinutes": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"workHours": { "type": "number", "default": 5 },
|
|
19
|
+
"offHours": { "type": "number", "default": 30 }
|
|
20
|
+
},
|
|
21
|
+
"default": { "workHours": 5, "offHours": 30 }
|
|
22
|
+
},
|
|
23
|
+
"workHours": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"start": { "type": "number", "default": 9 },
|
|
27
|
+
"end": { "type": "number", "default": 18 },
|
|
28
|
+
"timezone": { "type": "string", "default": "Europe/London" }
|
|
29
|
+
},
|
|
30
|
+
"default": { "start": 9, "end": 18, "timezone": "Europe/London" }
|
|
31
|
+
},
|
|
32
|
+
"classifierTimeoutMs": {
|
|
33
|
+
"type": "number",
|
|
34
|
+
"default": 30000
|
|
35
|
+
},
|
|
36
|
+
"consecutiveFailuresBeforeAlert": {
|
|
37
|
+
"type": "number",
|
|
38
|
+
"default": 3
|
|
39
|
+
},
|
|
40
|
+
"rescanDaysOnHistoryReset": {
|
|
41
|
+
"type": "number",
|
|
42
|
+
"default": 7
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"uiHints": {
|
|
47
|
+
"accounts": { "label": "Gmail accounts to poll", "placeholder": "user@gmail.com" },
|
|
48
|
+
"pollIntervalMinutes": { "label": "Poll intervals (minutes)" },
|
|
49
|
+
"workHours": { "label": "Work hours schedule" },
|
|
50
|
+
"classifierTimeoutMs": { "label": "Classifier timeout (ms)" },
|
|
51
|
+
"consecutiveFailuresBeforeAlert": { "label": "Failures before alerting agent" }
|
|
52
|
+
}
|
|
53
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@better_openclaw/betteremail",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Email digest plugin for OpenClaw — polls Gmail, deduplicates, classifies importance, exposes digest to agent",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"openclaw": {
|
|
8
|
+
"extensions": ["./src/index.ts"]
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@sinclair/typebox": "^0.34.0"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"vitest": "^3.0.0"
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
5
|
+
import type { TrimmedEmail, ClassificationResult } from "./types.js";
|
|
6
|
+
|
|
7
|
+
type RunEmbeddedPiAgentFn = (opts: Record<string, unknown>) => Promise<{ payloads?: unknown[] }>;
|
|
8
|
+
|
|
9
|
+
let _runFn: RunEmbeddedPiAgentFn | null = null;
|
|
10
|
+
|
|
11
|
+
async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
|
|
12
|
+
if (_runFn) return _runFn;
|
|
13
|
+
const mod = await import("../../../src/agents/pi-embedded.js").catch(() =>
|
|
14
|
+
import("openclaw/agents/pi-embedded"),
|
|
15
|
+
);
|
|
16
|
+
if (typeof (mod as any).runEmbeddedPiAgent !== "function") {
|
|
17
|
+
throw new Error("runEmbeddedPiAgent not available");
|
|
18
|
+
}
|
|
19
|
+
_runFn = (mod as any).runEmbeddedPiAgent as RunEmbeddedPiAgentFn;
|
|
20
|
+
return _runFn;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildClassifierPrompt(emails: TrimmedEmail[]): string {
|
|
24
|
+
const emailSummaries = emails.map((e) => ({
|
|
25
|
+
id: e.id,
|
|
26
|
+
from: e.from,
|
|
27
|
+
to: e.to,
|
|
28
|
+
subject: e.subject,
|
|
29
|
+
date: e.date,
|
|
30
|
+
body: e.body,
|
|
31
|
+
account: e.account,
|
|
32
|
+
threadLength: e.threadLength,
|
|
33
|
+
hasAttachments: e.hasAttachments,
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
return [
|
|
37
|
+
"You are triaging emails for the user. For each email, decide:",
|
|
38
|
+
'- importance: "high" | "medium" | "low"',
|
|
39
|
+
"- reason: one sentence explaining why",
|
|
40
|
+
"- notify: boolean (should the user be interrupted for this?)",
|
|
41
|
+
"",
|
|
42
|
+
"Consider: sender relationship, urgency signals, whether it requires action,",
|
|
43
|
+
"time sensitivity, financial/legal implications, personal importance.",
|
|
44
|
+
"",
|
|
45
|
+
"Respond with ONLY a valid JSON array. Each element must have: id, importance, reason, notify.",
|
|
46
|
+
"",
|
|
47
|
+
`Emails to triage (${emails.length}):`,
|
|
48
|
+
JSON.stringify(emailSummaries, null, 2),
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractText(payloads: unknown[]): string {
|
|
53
|
+
for (const p of payloads) {
|
|
54
|
+
if (typeof p === "string") return p;
|
|
55
|
+
if (p && typeof p === "object" && "text" in p && typeof (p as any).text === "string") {
|
|
56
|
+
return (p as any).text;
|
|
57
|
+
}
|
|
58
|
+
if (p && typeof p === "object" && "content" in p && Array.isArray((p as any).content)) {
|
|
59
|
+
for (const c of (p as any).content) {
|
|
60
|
+
if (c && typeof c.text === "string") return c.text;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function failOpenDefaults(emailIds: string[]): ClassificationResult[] {
|
|
68
|
+
return emailIds.map((id) => ({
|
|
69
|
+
id,
|
|
70
|
+
importance: "high" as const,
|
|
71
|
+
reason: "classification failed — fail open",
|
|
72
|
+
notify: true,
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseClassifierResponse(text: string, emailIds: string[]): ClassificationResult[] {
|
|
77
|
+
if (!text || !text.trim()) return failOpenDefaults(emailIds);
|
|
78
|
+
|
|
79
|
+
const cleaned = text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(cleaned);
|
|
83
|
+
if (!Array.isArray(parsed)) return failOpenDefaults(emailIds);
|
|
84
|
+
|
|
85
|
+
const resultsMap = new Map<string, ClassificationResult>();
|
|
86
|
+
for (const item of parsed) {
|
|
87
|
+
if (item && typeof item.id === "string") {
|
|
88
|
+
resultsMap.set(item.id, {
|
|
89
|
+
id: item.id,
|
|
90
|
+
importance: ["high", "medium", "low"].includes(item.importance) ? item.importance : "high",
|
|
91
|
+
reason: typeof item.reason === "string" ? item.reason : "no reason given",
|
|
92
|
+
notify: typeof item.notify === "boolean" ? item.notify : true,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return emailIds.map(
|
|
98
|
+
(id) =>
|
|
99
|
+
resultsMap.get(id) ?? {
|
|
100
|
+
id,
|
|
101
|
+
importance: "high" as const,
|
|
102
|
+
reason: "missing from classifier response — fail open",
|
|
103
|
+
notify: true,
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
} catch {
|
|
107
|
+
return failOpenDefaults(emailIds);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class Classifier {
|
|
112
|
+
private api: OpenClawPluginApi;
|
|
113
|
+
private timeoutMs: number;
|
|
114
|
+
|
|
115
|
+
constructor(api: OpenClawPluginApi, timeoutMs: number) {
|
|
116
|
+
this.api = api;
|
|
117
|
+
this.timeoutMs = timeoutMs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async classify(emails: TrimmedEmail[]): Promise<ClassificationResult[]> {
|
|
121
|
+
if (emails.length === 0) return [];
|
|
122
|
+
|
|
123
|
+
const prompt = buildClassifierPrompt(emails);
|
|
124
|
+
const emailIds = emails.map((e) => e.id);
|
|
125
|
+
|
|
126
|
+
let tmpDir: string | null = null;
|
|
127
|
+
try {
|
|
128
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "betteremail-classify-"));
|
|
129
|
+
const sessionFile = path.join(tmpDir, "session.json");
|
|
130
|
+
|
|
131
|
+
const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent();
|
|
132
|
+
|
|
133
|
+
const result = await runEmbeddedPiAgent({
|
|
134
|
+
sessionId: `betteremail-classifier-${Date.now()}`,
|
|
135
|
+
sessionFile,
|
|
136
|
+
workspaceDir: (this.api as any).config?.agents?.defaults?.workspace ?? process.cwd(),
|
|
137
|
+
config: (this.api as any).config,
|
|
138
|
+
prompt,
|
|
139
|
+
timeoutMs: this.timeoutMs,
|
|
140
|
+
runId: `betteremail-classify-${Date.now()}`,
|
|
141
|
+
disableTools: true,
|
|
142
|
+
skillsSnapshot: (this.api as any).skillsSnapshot ?? undefined,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const text = extractText(result.payloads ?? []);
|
|
146
|
+
return parseClassifierResponse(text, emailIds);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
this.api.logger.error(
|
|
149
|
+
`betteremail: classifier failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
150
|
+
);
|
|
151
|
+
return failOpenDefaults(emailIds);
|
|
152
|
+
} finally {
|
|
153
|
+
if (tmpDir) {
|
|
154
|
+
try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch {}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { DigestManager } from "../digest.js";
|
|
2
|
+
|
|
3
|
+
export function createEmailsCommandHandler(digest: DigestManager) {
|
|
4
|
+
return () => {
|
|
5
|
+
const grouped = digest.getGroupedByAccount("all");
|
|
6
|
+
const lines: string[] = [];
|
|
7
|
+
|
|
8
|
+
lines.push("Email Digest");
|
|
9
|
+
lines.push("\u2500".repeat(40));
|
|
10
|
+
|
|
11
|
+
let hasContent = false;
|
|
12
|
+
|
|
13
|
+
for (const [account, entries] of Object.entries(grouped)) {
|
|
14
|
+
const newEntries = entries.filter((e) => e.status === "new");
|
|
15
|
+
const surfaced = entries.filter((e) => e.status === "surfaced");
|
|
16
|
+
const deferred = entries.filter((e) => e.status === "deferred");
|
|
17
|
+
const handledToday = entries.filter(
|
|
18
|
+
(e) => e.status === "handled" && e.resolvedAt &&
|
|
19
|
+
new Date(e.resolvedAt).toDateString() === new Date().toDateString(),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const active = [...newEntries, ...surfaced];
|
|
23
|
+
if (active.length === 0 && deferred.length === 0) {
|
|
24
|
+
lines.push(`\n${account} — nothing new`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
hasContent = true;
|
|
29
|
+
lines.push(`\n${account} (${newEntries.length} new)`);
|
|
30
|
+
|
|
31
|
+
for (const entry of active) {
|
|
32
|
+
const imp = entry.importance === "high" ? "[HIGH]" : "[MED] ";
|
|
33
|
+
const age = formatAge(entry.firstSeenAt);
|
|
34
|
+
lines.push(` ${imp} ${entry.subject} from ${entry.from} — ${age}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (deferred.length > 0) {
|
|
38
|
+
lines.push(` ${deferred.length} deferred`);
|
|
39
|
+
}
|
|
40
|
+
if (handledToday.length > 0) {
|
|
41
|
+
lines.push(` ${handledToday.length} handled today`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!hasContent) {
|
|
46
|
+
lines.push("\nNo pending emails across all accounts.");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { text: lines.join("\n") };
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatAge(isoDate: string): string {
|
|
54
|
+
const ms = Date.now() - new Date(isoDate).getTime();
|
|
55
|
+
const minutes = Math.floor(ms / 60_000);
|
|
56
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
57
|
+
const hours = Math.floor(minutes / 60);
|
|
58
|
+
if (hours < 24) return `${hours}h ago`;
|
|
59
|
+
const days = Math.floor(hours / 24);
|
|
60
|
+
return `${days}d ago`;
|
|
61
|
+
}
|
package/src/digest.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { DigestEntry, DigestState, DigestStatus } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const DIGEST_FILE = "digest.json";
|
|
6
|
+
|
|
7
|
+
export class DigestManager {
|
|
8
|
+
private filePath: string;
|
|
9
|
+
private state: DigestState;
|
|
10
|
+
|
|
11
|
+
constructor(stateDir: string) {
|
|
12
|
+
this.filePath = path.join(stateDir, DIGEST_FILE);
|
|
13
|
+
this.state = { entries: {} };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async load(): Promise<void> {
|
|
17
|
+
try {
|
|
18
|
+
const raw = await fs.readFile(this.filePath, "utf8");
|
|
19
|
+
this.state = JSON.parse(raw) as DigestState;
|
|
20
|
+
} catch {
|
|
21
|
+
this.state = { entries: {} };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async save(): Promise<void> {
|
|
26
|
+
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
27
|
+
await fs.writeFile(this.filePath, JSON.stringify(this.state, null, 2) + "\n", "utf8");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
add(entry: DigestEntry): void {
|
|
31
|
+
this.state.entries[entry.id] = entry;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(id: string): DigestEntry | undefined {
|
|
35
|
+
return this.state.entries[id];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
has(id: string): boolean {
|
|
39
|
+
return id in this.state.entries;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getByStatus(status: DigestStatus | "all"): DigestEntry[] {
|
|
43
|
+
const entries = Object.values(this.state.entries);
|
|
44
|
+
if (status === "all") return entries;
|
|
45
|
+
return entries.filter((e) => e.status === status);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getGroupedByAccount(status: DigestStatus | "all"): Record<string, DigestEntry[]> {
|
|
49
|
+
const entries = this.getByStatus(status);
|
|
50
|
+
const grouped: Record<string, DigestEntry[]> = {};
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
if (!grouped[entry.account]) grouped[entry.account] = [];
|
|
53
|
+
grouped[entry.account].push(entry);
|
|
54
|
+
}
|
|
55
|
+
return grouped;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getActiveThreadIds(): DigestEntry[] {
|
|
59
|
+
return Object.values(this.state.entries).filter(
|
|
60
|
+
(e) => e.status === "surfaced" || e.status === "deferred",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
markSurfaced(id: string): void {
|
|
65
|
+
const entry = this.state.entries[id];
|
|
66
|
+
if (entry) {
|
|
67
|
+
entry.status = "surfaced";
|
|
68
|
+
entry.surfacedAt = new Date().toISOString();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
markHandled(id: string): void {
|
|
73
|
+
const entry = this.state.entries[id];
|
|
74
|
+
if (entry) {
|
|
75
|
+
entry.status = "handled";
|
|
76
|
+
entry.resolvedAt = new Date().toISOString();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
defer(id: string, minutes: number): void {
|
|
81
|
+
const entry = this.state.entries[id];
|
|
82
|
+
if (entry) {
|
|
83
|
+
entry.status = "deferred";
|
|
84
|
+
entry.deferredUntil = new Date(Date.now() + minutes * 60_000).toISOString();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
dismiss(id: string): void {
|
|
89
|
+
const entry = this.state.entries[id];
|
|
90
|
+
if (entry) {
|
|
91
|
+
entry.status = "dismissed";
|
|
92
|
+
entry.resolvedAt = new Date().toISOString();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
expireDeferrals(): DigestEntry[] {
|
|
97
|
+
const now = new Date();
|
|
98
|
+
const expired: DigestEntry[] = [];
|
|
99
|
+
for (const entry of Object.values(this.state.entries)) {
|
|
100
|
+
if (entry.status === "deferred" && entry.deferredUntil && new Date(entry.deferredUntil) <= now) {
|
|
101
|
+
entry.status = "new";
|
|
102
|
+
entry.deferredUntil = undefined;
|
|
103
|
+
expired.push(entry);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return expired;
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/email-log.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { EmailLogEntry } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const EMAILS_FILE = "emails.jsonl";
|
|
6
|
+
const DEFAULT_MAX_LINES = 10_000;
|
|
7
|
+
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
export class EmailLog {
|
|
10
|
+
private filePath: string;
|
|
11
|
+
|
|
12
|
+
constructor(stateDir: string) {
|
|
13
|
+
this.filePath = path.join(stateDir, EMAILS_FILE);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async append(entry: EmailLogEntry): Promise<void> {
|
|
17
|
+
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
18
|
+
const line = JSON.stringify(entry) + "\n";
|
|
19
|
+
await fs.appendFile(this.filePath, line, "utf8");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async readAll(): Promise<EmailLogEntry[]> {
|
|
23
|
+
try {
|
|
24
|
+
const raw = await fs.readFile(this.filePath, "utf8");
|
|
25
|
+
return raw
|
|
26
|
+
.trim()
|
|
27
|
+
.split("\n")
|
|
28
|
+
.filter((line) => line.length > 0)
|
|
29
|
+
.map((line) => JSON.parse(line) as EmailLogEntry);
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async hasMessageId(id: string): Promise<boolean> {
|
|
36
|
+
const all = await this.readAll();
|
|
37
|
+
return all.some((e) => e.email.id === id);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async rotate(maxLines: number = DEFAULT_MAX_LINES): Promise<number> {
|
|
41
|
+
const entries = await this.readAll();
|
|
42
|
+
if (entries.length <= maxLines) return 0;
|
|
43
|
+
|
|
44
|
+
const cutoff = Date.now() / 1000 - MAX_AGE_MS / 1000;
|
|
45
|
+
const kept = entries.filter((e) => e.timestamp >= cutoff).slice(-maxLines);
|
|
46
|
+
const removed = entries.length - kept.length;
|
|
47
|
+
|
|
48
|
+
const content = kept.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
49
|
+
await fs.writeFile(this.filePath, content, "utf8");
|
|
50
|
+
|
|
51
|
+
return removed;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { PluginConfig } from "./types.js";
|
|
3
|
+
import { DigestManager } from "./digest.js";
|
|
4
|
+
import { EmailLog } from "./email-log.js";
|
|
5
|
+
import { Poller } from "./poller.js";
|
|
6
|
+
import { Classifier } from "./classifier.js";
|
|
7
|
+
import { Scheduler } from "./scheduler.js";
|
|
8
|
+
import { runPipeline } from "./pipeline.js";
|
|
9
|
+
import { createGetEmailDigestTool } from "./tools/get-email-digest.js";
|
|
10
|
+
import { createMarkEmailHandledTool } from "./tools/mark-email-handled.js";
|
|
11
|
+
import { createDeferEmailTool } from "./tools/defer-email.js";
|
|
12
|
+
import { createDismissEmailTool } from "./tools/dismiss-email.js";
|
|
13
|
+
import { createEmailsCommandHandler } from "./commands/emails.js";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_CONFIG: PluginConfig = {
|
|
16
|
+
accounts: [],
|
|
17
|
+
pollIntervalMinutes: { workHours: 5, offHours: 30 },
|
|
18
|
+
workHours: { start: 9, end: 18, timezone: "Europe/London" },
|
|
19
|
+
classifierTimeoutMs: 30_000,
|
|
20
|
+
consecutiveFailuresBeforeAlert: 3,
|
|
21
|
+
rescanDaysOnHistoryReset: 7,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function resolveConfig(raw: Record<string, unknown> | undefined): PluginConfig {
|
|
25
|
+
return {
|
|
26
|
+
accounts: Array.isArray(raw?.accounts) ? (raw.accounts as string[]) : DEFAULT_CONFIG.accounts,
|
|
27
|
+
pollIntervalMinutes:
|
|
28
|
+
raw?.pollIntervalMinutes && typeof raw.pollIntervalMinutes === "object"
|
|
29
|
+
? {
|
|
30
|
+
workHours: typeof (raw.pollIntervalMinutes as any).workHours === "number"
|
|
31
|
+
? (raw.pollIntervalMinutes as any).workHours
|
|
32
|
+
: DEFAULT_CONFIG.pollIntervalMinutes.workHours,
|
|
33
|
+
offHours: typeof (raw.pollIntervalMinutes as any).offHours === "number"
|
|
34
|
+
? (raw.pollIntervalMinutes as any).offHours
|
|
35
|
+
: DEFAULT_CONFIG.pollIntervalMinutes.offHours,
|
|
36
|
+
}
|
|
37
|
+
: DEFAULT_CONFIG.pollIntervalMinutes,
|
|
38
|
+
workHours:
|
|
39
|
+
raw?.workHours && typeof raw.workHours === "object"
|
|
40
|
+
? {
|
|
41
|
+
start: typeof (raw.workHours as any).start === "number"
|
|
42
|
+
? (raw.workHours as any).start
|
|
43
|
+
: DEFAULT_CONFIG.workHours.start,
|
|
44
|
+
end: typeof (raw.workHours as any).end === "number"
|
|
45
|
+
? (raw.workHours as any).end
|
|
46
|
+
: DEFAULT_CONFIG.workHours.end,
|
|
47
|
+
timezone: typeof (raw.workHours as any).timezone === "string"
|
|
48
|
+
? (raw.workHours as any).timezone
|
|
49
|
+
: DEFAULT_CONFIG.workHours.timezone,
|
|
50
|
+
}
|
|
51
|
+
: DEFAULT_CONFIG.workHours,
|
|
52
|
+
classifierTimeoutMs:
|
|
53
|
+
typeof raw?.classifierTimeoutMs === "number" ? raw.classifierTimeoutMs : DEFAULT_CONFIG.classifierTimeoutMs,
|
|
54
|
+
consecutiveFailuresBeforeAlert:
|
|
55
|
+
typeof raw?.consecutiveFailuresBeforeAlert === "number"
|
|
56
|
+
? raw.consecutiveFailuresBeforeAlert
|
|
57
|
+
: DEFAULT_CONFIG.consecutiveFailuresBeforeAlert,
|
|
58
|
+
rescanDaysOnHistoryReset:
|
|
59
|
+
typeof raw?.rescanDaysOnHistoryReset === "number"
|
|
60
|
+
? raw.rescanDaysOnHistoryReset
|
|
61
|
+
: DEFAULT_CONFIG.rescanDaysOnHistoryReset,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default {
|
|
66
|
+
id: "betteremail",
|
|
67
|
+
name: "BetterEmail Digest",
|
|
68
|
+
|
|
69
|
+
register(api: OpenClawPluginApi) {
|
|
70
|
+
const config = resolveConfig(api.pluginConfig as Record<string, unknown> | undefined);
|
|
71
|
+
const stateDir = api.runtime.state.resolveStateDir();
|
|
72
|
+
|
|
73
|
+
api.logger.info(
|
|
74
|
+
`betteremail plugin loaded (accounts=${config.accounts.length}, ` +
|
|
75
|
+
`workHours=${config.pollIntervalMinutes.workHours}m, offHours=${config.pollIntervalMinutes.offHours}m)`,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (config.accounts.length === 0) {
|
|
79
|
+
api.logger.warn("betteremail: no accounts configured — plugin will not poll");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const digest = new DigestManager(stateDir);
|
|
84
|
+
const emailLog = new EmailLog(stateDir);
|
|
85
|
+
const poller = new Poller(api, stateDir, config.accounts, config.rescanDaysOnHistoryReset);
|
|
86
|
+
const classifier = new Classifier(api, config.classifierTimeoutMs);
|
|
87
|
+
|
|
88
|
+
let initialized = false;
|
|
89
|
+
const initPromise = (async () => {
|
|
90
|
+
try {
|
|
91
|
+
await digest.load();
|
|
92
|
+
await poller.loadState();
|
|
93
|
+
initialized = true;
|
|
94
|
+
api.logger.info("betteremail: async init complete");
|
|
95
|
+
} catch (err) {
|
|
96
|
+
api.logger.error(`betteremail: init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
|
|
100
|
+
api.registerTool(createGetEmailDigestTool(digest), { optional: true });
|
|
101
|
+
api.registerTool(createMarkEmailHandledTool(digest), { optional: true });
|
|
102
|
+
api.registerTool(createDeferEmailTool(digest), { optional: true });
|
|
103
|
+
api.registerTool(createDismissEmailTool(digest), { optional: true });
|
|
104
|
+
|
|
105
|
+
api.registerCommand({
|
|
106
|
+
name: "emails",
|
|
107
|
+
description: "Show current email digest status across all accounts",
|
|
108
|
+
handler: createEmailsCommandHandler(digest),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const runOnce = async () => {
|
|
112
|
+
if (!initialized) await initPromise;
|
|
113
|
+
|
|
114
|
+
await runPipeline({
|
|
115
|
+
accounts: config.accounts,
|
|
116
|
+
poller,
|
|
117
|
+
classifier,
|
|
118
|
+
digest,
|
|
119
|
+
emailLog,
|
|
120
|
+
logger: api.logger,
|
|
121
|
+
runCommand: (args, opts) => api.runtime.system.runCommandWithTimeout(args, opts),
|
|
122
|
+
consecutiveFailuresBeforeAlert: config.consecutiveFailuresBeforeAlert,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await emailLog.rotate();
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const scheduler = new Scheduler(
|
|
129
|
+
config.pollIntervalMinutes,
|
|
130
|
+
config.workHours,
|
|
131
|
+
runOnce,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
api.registerService({
|
|
135
|
+
id: "betteremail-poller",
|
|
136
|
+
start: () => {
|
|
137
|
+
scheduler.start();
|
|
138
|
+
api.logger.info("betteremail: polling service started");
|
|
139
|
+
},
|
|
140
|
+
stop: () => {
|
|
141
|
+
scheduler.stop();
|
|
142
|
+
api.logger.info("betteremail: polling service stopped");
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
};
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { TrimmedEmail, ClassificationResult, DigestEntry, EmailLogEntry } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export interface PipelineDeps {
|
|
4
|
+
accounts: string[];
|
|
5
|
+
poller: {
|
|
6
|
+
loadState(): Promise<void>;
|
|
7
|
+
saveState(): Promise<void>;
|
|
8
|
+
pollAccount(account: string, seenMessageIds: Set<string>): Promise<TrimmedEmail[]>;
|
|
9
|
+
recordSuccess(account: string, historyId: string): void;
|
|
10
|
+
recordFailure(account: string): number;
|
|
11
|
+
getAccountState(account: string): { historyId: string; lastPollAt: string; consecutiveFailures: number } | undefined;
|
|
12
|
+
checkThreadForReply(threadId: string, account: string): Promise<boolean>;
|
|
13
|
+
};
|
|
14
|
+
classifier: {
|
|
15
|
+
classify(emails: TrimmedEmail[]): Promise<ClassificationResult[]>;
|
|
16
|
+
};
|
|
17
|
+
digest: {
|
|
18
|
+
load(): Promise<void>;
|
|
19
|
+
save(): Promise<void>;
|
|
20
|
+
add(entry: DigestEntry): void;
|
|
21
|
+
has(id: string): boolean;
|
|
22
|
+
getActiveThreadIds(): DigestEntry[];
|
|
23
|
+
expireDeferrals(): DigestEntry[];
|
|
24
|
+
markHandled(id: string): void;
|
|
25
|
+
};
|
|
26
|
+
emailLog: {
|
|
27
|
+
append(entry: EmailLogEntry): Promise<void>;
|
|
28
|
+
hasMessageId(id: string): Promise<boolean>;
|
|
29
|
+
readAll(): Promise<EmailLogEntry[]>;
|
|
30
|
+
};
|
|
31
|
+
logger: {
|
|
32
|
+
info(msg: string): void;
|
|
33
|
+
warn(msg: string): void;
|
|
34
|
+
error(msg: string): void;
|
|
35
|
+
};
|
|
36
|
+
runCommand: (args: string[], opts: { timeoutMs: number }) => Promise<{ code: number; stdout?: string; stderr?: string }>;
|
|
37
|
+
consecutiveFailuresBeforeAlert: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const BATCH_SIZE = 10;
|
|
41
|
+
|
|
42
|
+
export async function runPipeline(deps: PipelineDeps): Promise<void> {
|
|
43
|
+
const { accounts, poller, classifier, digest, emailLog, logger } = deps;
|
|
44
|
+
|
|
45
|
+
await poller.loadState();
|
|
46
|
+
await digest.load();
|
|
47
|
+
|
|
48
|
+
const expired = digest.expireDeferrals();
|
|
49
|
+
if (expired.length > 0) {
|
|
50
|
+
logger.info(`betteremail: ${expired.length} deferred email(s) re-entered digest`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Auto-resolve: re-check active threads for owner replies
|
|
54
|
+
const activeEntries = digest.getActiveThreadIds();
|
|
55
|
+
for (const entry of activeEntries) {
|
|
56
|
+
try {
|
|
57
|
+
const replied = await poller.checkThreadForReply(entry.threadId, entry.account);
|
|
58
|
+
if (replied) {
|
|
59
|
+
digest.markHandled(entry.id);
|
|
60
|
+
logger.info(`betteremail: auto-resolved ${entry.id} — owner replied`);
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Non-critical — will retry next cycle
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Build seen IDs set from email log
|
|
68
|
+
const allLogEntries = await emailLog.readAll();
|
|
69
|
+
const seenIds = new Set(allLogEntries.map(e => e.email.id));
|
|
70
|
+
|
|
71
|
+
const allNewEmails: TrimmedEmail[] = [];
|
|
72
|
+
|
|
73
|
+
for (const account of accounts) {
|
|
74
|
+
try {
|
|
75
|
+
const emails = await poller.pollAccount(account, seenIds);
|
|
76
|
+
const filtered = emails.filter((e) => !digest.has(e.id));
|
|
77
|
+
allNewEmails.push(...filtered);
|
|
78
|
+
const currentState = poller.getAccountState(account);
|
|
79
|
+
poller.recordSuccess(account, currentState?.historyId ?? "");
|
|
80
|
+
logger.info(`betteremail: ${account} — ${emails.length} fetched, ${filtered.length} new`);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
const failures = poller.recordFailure(account);
|
|
83
|
+
logger.error(`betteremail: poll failed for ${account}: ${err instanceof Error ? err.message : String(err)}`);
|
|
84
|
+
|
|
85
|
+
if (failures >= deps.consecutiveFailuresBeforeAlert) {
|
|
86
|
+
try {
|
|
87
|
+
await deps.runCommand(
|
|
88
|
+
[
|
|
89
|
+
"openclaw", "agent",
|
|
90
|
+
"--session-id", "main",
|
|
91
|
+
"--deliver",
|
|
92
|
+
"--message", `[BetterEmail] Gmail polling has failed ${failures} times in a row for ${account}. Likely auth token expiry — please re-authenticate gog.`,
|
|
93
|
+
],
|
|
94
|
+
{ timeoutMs: 30_000 },
|
|
95
|
+
);
|
|
96
|
+
} catch {
|
|
97
|
+
logger.error("betteremail: failed to alert agent about polling failures");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (allNewEmails.length === 0) {
|
|
104
|
+
await digest.save();
|
|
105
|
+
await poller.saveState();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < allNewEmails.length; i += BATCH_SIZE) {
|
|
110
|
+
const batch = allNewEmails.slice(i, i + BATCH_SIZE);
|
|
111
|
+
const results = await classifier.classify(batch);
|
|
112
|
+
|
|
113
|
+
for (let j = 0; j < batch.length; j++) {
|
|
114
|
+
const email = batch[j];
|
|
115
|
+
const result = results[j];
|
|
116
|
+
|
|
117
|
+
await emailLog.append({
|
|
118
|
+
email,
|
|
119
|
+
importance: result.importance,
|
|
120
|
+
reason: result.reason,
|
|
121
|
+
notify: result.notify,
|
|
122
|
+
timestamp: Date.now() / 1000,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (result.importance === "high" || result.importance === "medium") {
|
|
126
|
+
const entry: DigestEntry = {
|
|
127
|
+
id: email.id,
|
|
128
|
+
threadId: email.threadId,
|
|
129
|
+
account: email.account,
|
|
130
|
+
from: email.from,
|
|
131
|
+
subject: email.subject,
|
|
132
|
+
date: email.date,
|
|
133
|
+
body: email.body,
|
|
134
|
+
importance: result.importance,
|
|
135
|
+
reason: result.reason,
|
|
136
|
+
notify: result.notify,
|
|
137
|
+
status: "new",
|
|
138
|
+
firstSeenAt: new Date().toISOString(),
|
|
139
|
+
};
|
|
140
|
+
digest.add(entry);
|
|
141
|
+
|
|
142
|
+
if (result.importance === "high" && result.notify) {
|
|
143
|
+
const message = formatPushMessage(email, result);
|
|
144
|
+
try {
|
|
145
|
+
await deps.runCommand(
|
|
146
|
+
[
|
|
147
|
+
"openclaw", "agent",
|
|
148
|
+
"--session-id", "main",
|
|
149
|
+
"--deliver",
|
|
150
|
+
"--message", message,
|
|
151
|
+
],
|
|
152
|
+
{ timeoutMs: 30_000 },
|
|
153
|
+
);
|
|
154
|
+
logger.info(`betteremail: pushed ${email.id} to agent`);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
logger.error(`betteremail: failed to push to agent: ${err instanceof Error ? err.message : String(err)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await digest.save();
|
|
164
|
+
await poller.saveState();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function formatPushMessage(email: TrimmedEmail, result: ClassificationResult): string {
|
|
168
|
+
return [
|
|
169
|
+
"[BetterEmail] New high-importance email:",
|
|
170
|
+
`From: ${email.from}`,
|
|
171
|
+
`Subject: ${email.subject}`,
|
|
172
|
+
`Account: ${email.account}`,
|
|
173
|
+
`Date: ${email.date}`,
|
|
174
|
+
`Reason: ${result.reason}`,
|
|
175
|
+
`MessageID: ${email.id}`,
|
|
176
|
+
"",
|
|
177
|
+
"Use defer_email to postpone or mark_email_handled when resolved.",
|
|
178
|
+
].join("\n");
|
|
179
|
+
}
|
package/src/poller.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { RawGogMessage, RawGogThread, PollState, TrimmedEmail } from "./types.js";
|
|
3
|
+
import { trimEmailBody } from "./trimmer.js";
|
|
4
|
+
import * as fs from "node:fs/promises";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
|
|
7
|
+
const STATE_FILE = "state.json";
|
|
8
|
+
|
|
9
|
+
export function parseGogMessages(stdout: string): RawGogMessage[] {
|
|
10
|
+
if (!stdout || !stdout.trim()) return [];
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(stdout.trim());
|
|
13
|
+
if (Array.isArray(parsed)) return parsed;
|
|
14
|
+
if (parsed && typeof parsed === "object" && parsed.id) return [parsed];
|
|
15
|
+
return [];
|
|
16
|
+
} catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseGogThread(stdout: string): RawGogThread | null {
|
|
22
|
+
if (!stdout || !stdout.trim()) return null;
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(stdout.trim());
|
|
25
|
+
if (parsed && parsed.id && Array.isArray(parsed.messages)) return parsed;
|
|
26
|
+
return null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractEmail(fromField: string): string {
|
|
33
|
+
const match = fromField.match(/<([^>]+)>/);
|
|
34
|
+
return (match ? match[1] : fromField).toLowerCase().trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function detectOwnerReply(thread: RawGogThread, ownerAccounts: string[]): boolean {
|
|
38
|
+
const lowerAccounts = ownerAccounts.map((a) => a.toLowerCase());
|
|
39
|
+
return thread.messages.some((msg) => {
|
|
40
|
+
if (!msg.from) return false;
|
|
41
|
+
const email = extractEmail(msg.from);
|
|
42
|
+
return lowerAccounts.includes(email);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class Poller {
|
|
47
|
+
private api: OpenClawPluginApi;
|
|
48
|
+
private stateDir: string;
|
|
49
|
+
private accounts: string[];
|
|
50
|
+
private rescanDays: number;
|
|
51
|
+
private state: PollState;
|
|
52
|
+
|
|
53
|
+
constructor(api: OpenClawPluginApi, stateDir: string, accounts: string[], rescanDays: number) {
|
|
54
|
+
this.api = api;
|
|
55
|
+
this.stateDir = stateDir;
|
|
56
|
+
this.accounts = accounts;
|
|
57
|
+
this.rescanDays = rescanDays;
|
|
58
|
+
this.state = { accounts: {}, lastClassifierRunAt: "" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async loadState(): Promise<void> {
|
|
62
|
+
try {
|
|
63
|
+
const raw = await fs.readFile(path.join(this.stateDir, STATE_FILE), "utf8");
|
|
64
|
+
this.state = JSON.parse(raw) as PollState;
|
|
65
|
+
} catch {
|
|
66
|
+
this.state = { accounts: {}, lastClassifierRunAt: "" };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async saveState(): Promise<void> {
|
|
71
|
+
await fs.mkdir(this.stateDir, { recursive: true });
|
|
72
|
+
await fs.writeFile(
|
|
73
|
+
path.join(this.stateDir, STATE_FILE),
|
|
74
|
+
JSON.stringify(this.state, null, 2) + "\n",
|
|
75
|
+
"utf8",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getAccountState(account: string) {
|
|
80
|
+
return this.state.accounts[account];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
recordSuccess(account: string, historyId: string): void {
|
|
84
|
+
this.state.accounts[account] = {
|
|
85
|
+
historyId,
|
|
86
|
+
lastPollAt: new Date().toISOString(),
|
|
87
|
+
consecutiveFailures: 0,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
recordFailure(account: string): number {
|
|
92
|
+
const existing = this.state.accounts[account];
|
|
93
|
+
const failures = (existing?.consecutiveFailures ?? 0) + 1;
|
|
94
|
+
this.state.accounts[account] = {
|
|
95
|
+
historyId: existing?.historyId ?? "",
|
|
96
|
+
lastPollAt: existing?.lastPollAt ?? "",
|
|
97
|
+
consecutiveFailures: failures,
|
|
98
|
+
};
|
|
99
|
+
return failures;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async runGog(args: string[]): Promise<{ stdout: string; ok: boolean }> {
|
|
103
|
+
try {
|
|
104
|
+
const result = await this.api.runtime.system.runCommandWithTimeout(
|
|
105
|
+
["gog", ...args],
|
|
106
|
+
{ timeoutMs: 30_000 },
|
|
107
|
+
);
|
|
108
|
+
if (result.code !== 0) {
|
|
109
|
+
this.api.logger.warn(`betteremail: gog failed (code ${result.code}): ${result.stderr?.slice(0, 200)}`);
|
|
110
|
+
return { stdout: "", ok: false };
|
|
111
|
+
}
|
|
112
|
+
return { stdout: result.stdout ?? "", ok: true };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
this.api.logger.error(`betteremail: gog error: ${err instanceof Error ? err.message : String(err)}`);
|
|
115
|
+
return { stdout: "", ok: false };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async checkThreadForReply(threadId: string, account: string): Promise<boolean> {
|
|
120
|
+
const result = await this.runGog([
|
|
121
|
+
"gmail", "thread", "get", threadId, "--account", account, "--json",
|
|
122
|
+
]);
|
|
123
|
+
if (!result.ok) return false;
|
|
124
|
+
const thread = parseGogThread(result.stdout);
|
|
125
|
+
if (!thread) return false;
|
|
126
|
+
return detectOwnerReply(thread, this.accounts);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async pollAccount(account: string, seenMessageIds: Set<string>): Promise<TrimmedEmail[]> {
|
|
130
|
+
const accountState = this.state.accounts[account];
|
|
131
|
+
const historyId = accountState?.historyId;
|
|
132
|
+
|
|
133
|
+
let messages: RawGogMessage[];
|
|
134
|
+
|
|
135
|
+
if (historyId) {
|
|
136
|
+
const result = await this.runGog([
|
|
137
|
+
"gmail", "history", "--since", historyId, "--account", account, "--json",
|
|
138
|
+
]);
|
|
139
|
+
if (!result.ok) {
|
|
140
|
+
this.api.logger.info(`betteremail: history fetch failed for ${account}, falling back to rescan`);
|
|
141
|
+
const fallback = await this.runGog([
|
|
142
|
+
"gmail", "messages", "search", `newer_than:${this.rescanDays}d`,
|
|
143
|
+
"--account", account, "--json", "--include-body",
|
|
144
|
+
]);
|
|
145
|
+
if (!fallback.ok) return [];
|
|
146
|
+
messages = parseGogMessages(fallback.stdout);
|
|
147
|
+
} else {
|
|
148
|
+
messages = parseGogMessages(result.stdout);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
const result = await this.runGog([
|
|
152
|
+
"gmail", "messages", "search", `newer_than:${this.rescanDays}d`,
|
|
153
|
+
"--account", account, "--json", "--include-body",
|
|
154
|
+
]);
|
|
155
|
+
if (!result.ok) return [];
|
|
156
|
+
messages = parseGogMessages(result.stdout);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const newMessages = messages.filter((m) => !seenMessageIds.has(m.id));
|
|
160
|
+
const trimmedEmails: TrimmedEmail[] = [];
|
|
161
|
+
|
|
162
|
+
for (const msg of newMessages) {
|
|
163
|
+
const threadResult = await this.runGog([
|
|
164
|
+
"gmail", "thread", "get", msg.threadId, "--account", account, "--json",
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
const thread = threadResult.ok ? parseGogThread(threadResult.stdout) : null;
|
|
168
|
+
|
|
169
|
+
if (thread && detectOwnerReply(thread, this.accounts)) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
trimmedEmails.push({
|
|
174
|
+
id: msg.id,
|
|
175
|
+
threadId: msg.threadId,
|
|
176
|
+
account,
|
|
177
|
+
from: msg.from ?? "unknown",
|
|
178
|
+
to: msg.to ?? account,
|
|
179
|
+
subject: msg.subject ?? "(no subject)",
|
|
180
|
+
date: msg.date ?? new Date().toISOString(),
|
|
181
|
+
body: trimEmailBody(msg.body ?? ""),
|
|
182
|
+
threadLength: thread?.messages.length ?? 1,
|
|
183
|
+
hasAttachments: Array.isArray(msg.labelIds) && msg.labelIds.includes("ATTACHMENT"),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return trimmedEmails;
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { WorkHoursConfig, PollIntervalConfig } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function isWorkHours(now: Date, config: WorkHoursConfig): boolean {
|
|
4
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
5
|
+
hour: "numeric",
|
|
6
|
+
hour12: false,
|
|
7
|
+
timeZone: config.timezone,
|
|
8
|
+
});
|
|
9
|
+
const hour = parseInt(formatter.format(now), 10);
|
|
10
|
+
return hour >= config.start && hour < config.end;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getIntervalMs(
|
|
14
|
+
now: Date,
|
|
15
|
+
intervals: PollIntervalConfig,
|
|
16
|
+
workConfig: WorkHoursConfig,
|
|
17
|
+
): number {
|
|
18
|
+
const minutes = isWorkHours(now, workConfig) ? intervals.workHours : intervals.offHours;
|
|
19
|
+
return minutes * 60 * 1000;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class Scheduler {
|
|
23
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
24
|
+
private running = false;
|
|
25
|
+
private intervals: PollIntervalConfig;
|
|
26
|
+
private workConfig: WorkHoursConfig;
|
|
27
|
+
private onTick: () => Promise<void>;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
intervals: PollIntervalConfig,
|
|
31
|
+
workConfig: WorkHoursConfig,
|
|
32
|
+
onTick: () => Promise<void>,
|
|
33
|
+
) {
|
|
34
|
+
this.intervals = intervals;
|
|
35
|
+
this.workConfig = workConfig;
|
|
36
|
+
this.onTick = onTick;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
start(): void {
|
|
40
|
+
if (this.running) return;
|
|
41
|
+
this.running = true;
|
|
42
|
+
this.scheduleNext();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
stop(): void {
|
|
46
|
+
this.running = false;
|
|
47
|
+
if (this.timer) {
|
|
48
|
+
clearTimeout(this.timer);
|
|
49
|
+
this.timer = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private scheduleNext(): void {
|
|
54
|
+
if (!this.running) return;
|
|
55
|
+
const interval = getIntervalMs(new Date(), this.intervals, this.workConfig);
|
|
56
|
+
this.timer = setTimeout(async () => {
|
|
57
|
+
try {
|
|
58
|
+
await this.onTick();
|
|
59
|
+
} catch {
|
|
60
|
+
// Pipeline handles its own errors — scheduler just keeps going
|
|
61
|
+
}
|
|
62
|
+
this.scheduleNext();
|
|
63
|
+
}, interval);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { DigestManager } from "../digest.js";
|
|
3
|
+
|
|
4
|
+
export function createDeferEmailTool(digest: DigestManager) {
|
|
5
|
+
return {
|
|
6
|
+
name: "defer_email",
|
|
7
|
+
label: "Defer Email",
|
|
8
|
+
description:
|
|
9
|
+
"Defer an email — it will re-appear in the digest after the specified number of minutes. " +
|
|
10
|
+
"Use this when the user can't deal with it right now (e.g., in a meeting).",
|
|
11
|
+
parameters: Type.Object({
|
|
12
|
+
messageId: Type.String({ description: "The message ID to defer" }),
|
|
13
|
+
minutes: Type.Number({ description: "Minutes until the email re-surfaces in the digest" }),
|
|
14
|
+
}),
|
|
15
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
16
|
+
const messageId = params.messageId as string;
|
|
17
|
+
const minutes = params.minutes as number;
|
|
18
|
+
const entry = digest.get(messageId);
|
|
19
|
+
if (!entry) {
|
|
20
|
+
return { content: [{ type: "text" as const, text: `Email ${messageId} not found in digest.` }] };
|
|
21
|
+
}
|
|
22
|
+
digest.defer(messageId, minutes);
|
|
23
|
+
await digest.save();
|
|
24
|
+
return {
|
|
25
|
+
content: [{
|
|
26
|
+
type: "text" as const,
|
|
27
|
+
text: `Deferred "${entry.subject}" — will re-surface in ${minutes} minutes.`,
|
|
28
|
+
}],
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { DigestManager } from "../digest.js";
|
|
3
|
+
|
|
4
|
+
export function createDismissEmailTool(digest: DigestManager) {
|
|
5
|
+
return {
|
|
6
|
+
name: "dismiss_email",
|
|
7
|
+
label: "Dismiss Email",
|
|
8
|
+
description: "Permanently dismiss an email from the digest. It will never be re-flagged.",
|
|
9
|
+
parameters: Type.Object({
|
|
10
|
+
messageId: Type.String({ description: "The message ID to dismiss" }),
|
|
11
|
+
reason: Type.Optional(Type.String({ description: "Optional reason for dismissing" })),
|
|
12
|
+
}),
|
|
13
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
14
|
+
const messageId = params.messageId as string;
|
|
15
|
+
const entry = digest.get(messageId);
|
|
16
|
+
if (!entry) {
|
|
17
|
+
return { content: [{ type: "text" as const, text: `Email ${messageId} not found in digest.` }] };
|
|
18
|
+
}
|
|
19
|
+
digest.dismiss(messageId);
|
|
20
|
+
await digest.save();
|
|
21
|
+
return {
|
|
22
|
+
content: [{ type: "text" as const, text: `Dismissed "${entry.subject}" from ${entry.from}. It won't be flagged again.` }],
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { DigestManager } from "../digest.js";
|
|
3
|
+
import type { DigestStatus } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export function createGetEmailDigestTool(digest: DigestManager) {
|
|
6
|
+
return {
|
|
7
|
+
name: "get_email_digest",
|
|
8
|
+
label: "Get Email Digest",
|
|
9
|
+
description:
|
|
10
|
+
"Get current email digest — unresolved important emails from all Gmail accounts. " +
|
|
11
|
+
"Returns emails grouped by account with importance level, reason, and age. " +
|
|
12
|
+
"Call this to check for new emails or review pending items.",
|
|
13
|
+
parameters: Type.Object({
|
|
14
|
+
status: Type.Optional(
|
|
15
|
+
Type.String({
|
|
16
|
+
description: 'Filter by status: "new", "surfaced", "deferred", or "all". Default: "new"',
|
|
17
|
+
}),
|
|
18
|
+
),
|
|
19
|
+
account: Type.Optional(
|
|
20
|
+
Type.String({ description: "Filter by account email address" }),
|
|
21
|
+
),
|
|
22
|
+
}),
|
|
23
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
24
|
+
const status = (typeof params.status === "string" ? params.status : "new") as DigestStatus | "all";
|
|
25
|
+
const account = typeof params.account === "string" ? params.account : undefined;
|
|
26
|
+
|
|
27
|
+
let grouped = digest.getGroupedByAccount(status);
|
|
28
|
+
|
|
29
|
+
if (account) {
|
|
30
|
+
grouped = { [account]: grouped[account] ?? [] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const entries of Object.values(grouped)) {
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (entry.status === "new") {
|
|
36
|
+
digest.markSurfaced(entry.id);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const summary: Record<string, unknown[]> = {};
|
|
42
|
+
for (const [acc, entries] of Object.entries(grouped)) {
|
|
43
|
+
summary[acc] = entries.map((e) => ({
|
|
44
|
+
messageId: e.id,
|
|
45
|
+
from: e.from,
|
|
46
|
+
subject: e.subject,
|
|
47
|
+
importance: e.importance,
|
|
48
|
+
reason: e.reason,
|
|
49
|
+
status: e.status,
|
|
50
|
+
date: e.date,
|
|
51
|
+
age: formatAge(e.firstSeenAt),
|
|
52
|
+
body: e.body,
|
|
53
|
+
deferredUntil: e.deferredUntil ?? undefined,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await digest.save();
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: "text" as const, text: JSON.stringify(summary, null, 2) }],
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatAge(isoDate: string): string {
|
|
67
|
+
const ms = Date.now() - new Date(isoDate).getTime();
|
|
68
|
+
const minutes = Math.floor(ms / 60_000);
|
|
69
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
70
|
+
const hours = Math.floor(minutes / 60);
|
|
71
|
+
if (hours < 24) return `${hours}h ago`;
|
|
72
|
+
const days = Math.floor(hours / 24);
|
|
73
|
+
return `${days}d ago`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { DigestManager } from "../digest.js";
|
|
3
|
+
|
|
4
|
+
export function createMarkEmailHandledTool(digest: DigestManager) {
|
|
5
|
+
return {
|
|
6
|
+
name: "mark_email_handled",
|
|
7
|
+
label: "Mark Email Handled",
|
|
8
|
+
description: "Mark an email as handled/dealt with. It will no longer appear in the digest.",
|
|
9
|
+
parameters: Type.Object({
|
|
10
|
+
messageId: Type.String({ description: "The message ID to mark as handled" }),
|
|
11
|
+
}),
|
|
12
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
13
|
+
const messageId = params.messageId as string;
|
|
14
|
+
const entry = digest.get(messageId);
|
|
15
|
+
if (!entry) {
|
|
16
|
+
return { content: [{ type: "text" as const, text: `Email ${messageId} not found in digest.` }] };
|
|
17
|
+
}
|
|
18
|
+
digest.markHandled(messageId);
|
|
19
|
+
await digest.save();
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text" as const, text: `Marked "${entry.subject}" from ${entry.from} as handled.` }],
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
package/src/trimmer.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const DEFAULT_MAX_LENGTH = 3000;
|
|
2
|
+
|
|
3
|
+
const HTML_ENTITY_MAP: Record<string, string> = {
|
|
4
|
+
"&": "&",
|
|
5
|
+
"<": "<",
|
|
6
|
+
">": ">",
|
|
7
|
+
""": '"',
|
|
8
|
+
"'": "'",
|
|
9
|
+
"'": "'",
|
|
10
|
+
" ": " ",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function trimEmailBody(raw: string, maxLength: number = DEFAULT_MAX_LENGTH): string {
|
|
14
|
+
if (!raw || typeof raw !== "string") return "";
|
|
15
|
+
|
|
16
|
+
let body = raw;
|
|
17
|
+
|
|
18
|
+
// 1. Strip HTML tags
|
|
19
|
+
body = body.replace(/<[^>]*>/g, "");
|
|
20
|
+
|
|
21
|
+
// 2. Decode HTML entities
|
|
22
|
+
body = body.replace(/&\w+;|&#\d+;/g, (match) => HTML_ENTITY_MAP[match] ?? match);
|
|
23
|
+
|
|
24
|
+
// 3. Remove quoted reply chains ("On <date>, <person> wrote:")
|
|
25
|
+
body = body.replace(/\n*On\s+.{10,80}\s+wrote:\s*\n(>[^\n]*\n?)*/gi, "");
|
|
26
|
+
|
|
27
|
+
// 4. Remove > prefixed quote blocks
|
|
28
|
+
body = body.replace(/\n*(?:^|\n)(>[^\n]*\n?)+/g, "");
|
|
29
|
+
|
|
30
|
+
// 5. Remove email signatures (-- delimiter)
|
|
31
|
+
body = body.replace(/\n--\s*\n[\s\S]*$/m, "");
|
|
32
|
+
|
|
33
|
+
// 6. Remove "Sent from" signatures
|
|
34
|
+
body = body.replace(/\n*Sent from my [\w\s]+$/i, "");
|
|
35
|
+
body = body.replace(/\n*(?:Best regards|Kind regards|Regards|Cheers|Thanks|Best),?\s*\n[\s\S]{0,200}$/i, "");
|
|
36
|
+
|
|
37
|
+
// 7. Remove legal disclaimers
|
|
38
|
+
body = body.replace(/\n*(?:This email is confidential|CONFIDENTIALITY NOTICE|DISCLAIMER|If you (?:are not|received this in error))[\s\S]*$/i, "");
|
|
39
|
+
|
|
40
|
+
// 8. Remove tracking pixels / image references
|
|
41
|
+
body = body.replace(/\[(?:image|cid:[^\]]*)\]/gi, "");
|
|
42
|
+
body = body.replace(/\[https?:\/\/[^\]]*\.(?:png|gif|jpg|jpeg|bmp)\]/gi, "");
|
|
43
|
+
|
|
44
|
+
// 9. Collapse excessive whitespace
|
|
45
|
+
body = body.replace(/\n{3,}/g, "\n\n");
|
|
46
|
+
body = body.trim();
|
|
47
|
+
|
|
48
|
+
// 10. Truncate
|
|
49
|
+
if (body.length > maxLength) {
|
|
50
|
+
body = body.slice(0, maxLength);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return body;
|
|
54
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// -- Plugin config --
|
|
2
|
+
|
|
3
|
+
export interface PollIntervalConfig {
|
|
4
|
+
workHours: number;
|
|
5
|
+
offHours: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface WorkHoursConfig {
|
|
9
|
+
start: number;
|
|
10
|
+
end: number;
|
|
11
|
+
timezone: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PluginConfig {
|
|
15
|
+
accounts: string[];
|
|
16
|
+
pollIntervalMinutes: PollIntervalConfig;
|
|
17
|
+
workHours: WorkHoursConfig;
|
|
18
|
+
classifierTimeoutMs: number;
|
|
19
|
+
consecutiveFailuresBeforeAlert: number;
|
|
20
|
+
rescanDaysOnHistoryReset: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// -- Raw email from gog CLI --
|
|
24
|
+
|
|
25
|
+
export interface RawGogMessage {
|
|
26
|
+
id: string;
|
|
27
|
+
threadId: string;
|
|
28
|
+
subject?: string;
|
|
29
|
+
from?: string;
|
|
30
|
+
to?: string;
|
|
31
|
+
date?: string;
|
|
32
|
+
body?: string;
|
|
33
|
+
labelIds?: string[];
|
|
34
|
+
[key: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RawGogThread {
|
|
38
|
+
id: string;
|
|
39
|
+
messages: RawGogMessage[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// -- Trimmed email ready for classification --
|
|
43
|
+
|
|
44
|
+
export interface TrimmedEmail {
|
|
45
|
+
id: string;
|
|
46
|
+
threadId: string;
|
|
47
|
+
account: string;
|
|
48
|
+
from: string;
|
|
49
|
+
to: string;
|
|
50
|
+
subject: string;
|
|
51
|
+
date: string;
|
|
52
|
+
body: string;
|
|
53
|
+
threadLength: number;
|
|
54
|
+
hasAttachments: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// -- Classification result --
|
|
58
|
+
|
|
59
|
+
export type ImportanceLevel = "high" | "medium" | "low";
|
|
60
|
+
|
|
61
|
+
export interface ClassificationResult {
|
|
62
|
+
id: string;
|
|
63
|
+
importance: ImportanceLevel;
|
|
64
|
+
reason: string;
|
|
65
|
+
notify: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// -- Digest entry --
|
|
69
|
+
|
|
70
|
+
export type DigestStatus = "new" | "surfaced" | "deferred" | "handled" | "dismissed";
|
|
71
|
+
|
|
72
|
+
export interface DigestEntry {
|
|
73
|
+
id: string;
|
|
74
|
+
threadId: string;
|
|
75
|
+
account: string;
|
|
76
|
+
from: string;
|
|
77
|
+
subject: string;
|
|
78
|
+
date: string;
|
|
79
|
+
body: string;
|
|
80
|
+
importance: "high" | "medium";
|
|
81
|
+
reason: string;
|
|
82
|
+
notify: boolean;
|
|
83
|
+
status: DigestStatus;
|
|
84
|
+
firstSeenAt: string;
|
|
85
|
+
surfacedAt?: string;
|
|
86
|
+
deferredUntil?: string;
|
|
87
|
+
resolvedAt?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// -- Digest state file --
|
|
91
|
+
|
|
92
|
+
export interface DigestState {
|
|
93
|
+
entries: Record<string, DigestEntry>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// -- Polling state file --
|
|
97
|
+
|
|
98
|
+
export interface AccountState {
|
|
99
|
+
historyId: string;
|
|
100
|
+
lastPollAt: string;
|
|
101
|
+
consecutiveFailures: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface PollState {
|
|
105
|
+
accounts: Record<string, AccountState>;
|
|
106
|
+
lastClassifierRunAt: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// -- Email log entry (emails.jsonl) --
|
|
110
|
+
|
|
111
|
+
export interface EmailLogEntry {
|
|
112
|
+
email: TrimmedEmail;
|
|
113
|
+
importance: ImportanceLevel;
|
|
114
|
+
reason: string;
|
|
115
|
+
notify: boolean;
|
|
116
|
+
timestamp: number;
|
|
117
|
+
}
|