@better_openclaw/betteremail 0.1.0 → 0.2.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/package.json +5 -1
- package/src/atomic.ts +26 -0
- package/src/commands/emails.ts +1 -10
- package/src/digest.ts +18 -5
- package/src/email-log.ts +2 -6
- package/src/index.ts +14 -25
- package/src/pipeline.ts +5 -7
- package/src/poller.ts +28 -11
- package/src/tools/defer-email.ts +11 -2
- package/src/tools/dismiss-email.ts +9 -2
- package/src/tools/get-email-digest.ts +15 -18
- package/src/tools/mark-email-handled.ts +7 -1
- package/src/types.ts +1 -0
- package/src/utils.ts +9 -0
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better_openclaw/betteremail",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Email digest plugin for OpenClaw — polls Gmail, deduplicates, classifies importance, exposes digest to agent",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/maximilianspitzer/openclaw-betteremail"
|
|
9
|
+
},
|
|
6
10
|
"type": "module",
|
|
7
11
|
"openclaw": {
|
|
8
12
|
"extensions": ["./src/index.ts"]
|
package/src/atomic.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Write content to a file atomically by first writing to a temp file
|
|
7
|
+
* in the same directory, then renaming. This prevents corruption on
|
|
8
|
+
* crash mid-write since rename is atomic on POSIX filesystems.
|
|
9
|
+
*/
|
|
10
|
+
export async function atomicWrite(filePath: string, content: string): Promise<void> {
|
|
11
|
+
const dir = path.dirname(filePath);
|
|
12
|
+
await fs.mkdir(dir, { recursive: true });
|
|
13
|
+
const tmpPath = path.join(dir, `.${path.basename(filePath)}.${crypto.randomBytes(4).toString("hex")}.tmp`);
|
|
14
|
+
try {
|
|
15
|
+
await fs.writeFile(tmpPath, content, "utf8");
|
|
16
|
+
await fs.rename(tmpPath, filePath);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
// Clean up temp file on failure
|
|
19
|
+
try {
|
|
20
|
+
await fs.unlink(tmpPath);
|
|
21
|
+
} catch {
|
|
22
|
+
// Ignore cleanup errors
|
|
23
|
+
}
|
|
24
|
+
throw err;
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/commands/emails.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DigestManager } from "../digest.js";
|
|
2
|
+
import { formatAge } from "../utils.js";
|
|
2
3
|
|
|
3
4
|
export function createEmailsCommandHandler(digest: DigestManager) {
|
|
4
5
|
return () => {
|
|
@@ -49,13 +50,3 @@ export function createEmailsCommandHandler(digest: DigestManager) {
|
|
|
49
50
|
return { text: lines.join("\n") };
|
|
50
51
|
};
|
|
51
52
|
}
|
|
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
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { DigestEntry, DigestState, DigestStatus } from "./types.js";
|
|
4
|
+
import { atomicWrite } from "./atomic.js";
|
|
4
5
|
|
|
5
6
|
const DIGEST_FILE = "digest.json";
|
|
6
7
|
|
|
7
8
|
export class DigestManager {
|
|
8
9
|
private filePath: string;
|
|
9
10
|
private state: DigestState;
|
|
11
|
+
private saving = false;
|
|
12
|
+
private saveQueue: (() => void)[] = [];
|
|
10
13
|
|
|
11
14
|
constructor(stateDir: string) {
|
|
12
15
|
this.filePath = path.join(stateDir, DIGEST_FILE);
|
|
@@ -23,8 +26,17 @@ export class DigestManager {
|
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
async save(): Promise<void> {
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
if (this.saving) {
|
|
30
|
+
await new Promise<void>((resolve) => this.saveQueue.push(resolve));
|
|
31
|
+
}
|
|
32
|
+
this.saving = true;
|
|
33
|
+
try {
|
|
34
|
+
await atomicWrite(this.filePath, JSON.stringify(this.state, null, 2) + "\n");
|
|
35
|
+
} finally {
|
|
36
|
+
this.saving = false;
|
|
37
|
+
const next = this.saveQueue.shift();
|
|
38
|
+
if (next) next();
|
|
39
|
+
}
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
add(entry: DigestEntry): void {
|
|
@@ -55,9 +67,9 @@ export class DigestManager {
|
|
|
55
67
|
return grouped;
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
|
|
70
|
+
getActiveEntries(): DigestEntry[] {
|
|
59
71
|
return Object.values(this.state.entries).filter(
|
|
60
|
-
(e) => e.status === "surfaced" || e.status === "deferred",
|
|
72
|
+
(e) => e.status === "new" || e.status === "surfaced" || e.status === "deferred",
|
|
61
73
|
);
|
|
62
74
|
}
|
|
63
75
|
|
|
@@ -85,11 +97,12 @@ export class DigestManager {
|
|
|
85
97
|
}
|
|
86
98
|
}
|
|
87
99
|
|
|
88
|
-
dismiss(id: string): void {
|
|
100
|
+
dismiss(id: string, reason?: string): void {
|
|
89
101
|
const entry = this.state.entries[id];
|
|
90
102
|
if (entry) {
|
|
91
103
|
entry.status = "dismissed";
|
|
92
104
|
entry.resolvedAt = new Date().toISOString();
|
|
105
|
+
if (reason) entry.dismissReason = reason;
|
|
93
106
|
}
|
|
94
107
|
}
|
|
95
108
|
|
package/src/email-log.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { EmailLogEntry } from "./types.js";
|
|
4
|
+
import { atomicWrite } from "./atomic.js";
|
|
4
5
|
|
|
5
6
|
const EMAILS_FILE = "emails.jsonl";
|
|
6
7
|
const DEFAULT_MAX_LINES = 10_000;
|
|
@@ -32,11 +33,6 @@ export class EmailLog {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
|
|
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
36
|
async rotate(maxLines: number = DEFAULT_MAX_LINES): Promise<number> {
|
|
41
37
|
const entries = await this.readAll();
|
|
42
38
|
if (entries.length <= maxLines) return 0;
|
|
@@ -46,7 +42,7 @@ export class EmailLog {
|
|
|
46
42
|
const removed = entries.length - kept.length;
|
|
47
43
|
|
|
48
44
|
const content = kept.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
49
|
-
await
|
|
45
|
+
await atomicWrite(this.filePath, content);
|
|
50
46
|
|
|
51
47
|
return removed;
|
|
52
48
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,33 +22,22 @@ const DEFAULT_CONFIG: PluginConfig = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
function resolveConfig(raw: Record<string, unknown> | undefined): PluginConfig {
|
|
25
|
+
const pollIntervalRaw = raw?.pollIntervalMinutes && typeof raw.pollIntervalMinutes === "object"
|
|
26
|
+
? raw.pollIntervalMinutes as Record<string, unknown> : null;
|
|
27
|
+
const workHoursRaw = raw?.workHours && typeof raw.workHours === "object"
|
|
28
|
+
? raw.workHours as Record<string, unknown> : null;
|
|
29
|
+
|
|
25
30
|
return {
|
|
26
31
|
accounts: Array.isArray(raw?.accounts) ? (raw.accounts as string[]) : DEFAULT_CONFIG.accounts,
|
|
27
|
-
pollIntervalMinutes:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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,
|
|
32
|
+
pollIntervalMinutes: pollIntervalRaw ? {
|
|
33
|
+
workHours: typeof pollIntervalRaw.workHours === "number" ? pollIntervalRaw.workHours : DEFAULT_CONFIG.pollIntervalMinutes.workHours,
|
|
34
|
+
offHours: typeof pollIntervalRaw.offHours === "number" ? pollIntervalRaw.offHours : DEFAULT_CONFIG.pollIntervalMinutes.offHours,
|
|
35
|
+
} : DEFAULT_CONFIG.pollIntervalMinutes,
|
|
36
|
+
workHours: workHoursRaw ? {
|
|
37
|
+
start: typeof workHoursRaw.start === "number" ? workHoursRaw.start : DEFAULT_CONFIG.workHours.start,
|
|
38
|
+
end: typeof workHoursRaw.end === "number" ? workHoursRaw.end : DEFAULT_CONFIG.workHours.end,
|
|
39
|
+
timezone: typeof workHoursRaw.timezone === "string" ? workHoursRaw.timezone : DEFAULT_CONFIG.workHours.timezone,
|
|
40
|
+
} : DEFAULT_CONFIG.workHours,
|
|
52
41
|
classifierTimeoutMs:
|
|
53
42
|
typeof raw?.classifierTimeoutMs === "number" ? raw.classifierTimeoutMs : DEFAULT_CONFIG.classifierTimeoutMs,
|
|
54
43
|
consecutiveFailuresBeforeAlert:
|
package/src/pipeline.ts
CHANGED
|
@@ -5,7 +5,7 @@ export interface PipelineDeps {
|
|
|
5
5
|
poller: {
|
|
6
6
|
loadState(): Promise<void>;
|
|
7
7
|
saveState(): Promise<void>;
|
|
8
|
-
pollAccount(account: string, seenMessageIds: Set<string>): Promise<TrimmedEmail[]>;
|
|
8
|
+
pollAccount(account: string, seenMessageIds: Set<string>): Promise<{ emails: TrimmedEmail[]; historyId?: string }>;
|
|
9
9
|
recordSuccess(account: string, historyId: string): void;
|
|
10
10
|
recordFailure(account: string): number;
|
|
11
11
|
getAccountState(account: string): { historyId: string; lastPollAt: string; consecutiveFailures: number } | undefined;
|
|
@@ -19,13 +19,12 @@ export interface PipelineDeps {
|
|
|
19
19
|
save(): Promise<void>;
|
|
20
20
|
add(entry: DigestEntry): void;
|
|
21
21
|
has(id: string): boolean;
|
|
22
|
-
|
|
22
|
+
getActiveEntries(): DigestEntry[];
|
|
23
23
|
expireDeferrals(): DigestEntry[];
|
|
24
24
|
markHandled(id: string): void;
|
|
25
25
|
};
|
|
26
26
|
emailLog: {
|
|
27
27
|
append(entry: EmailLogEntry): Promise<void>;
|
|
28
|
-
hasMessageId(id: string): Promise<boolean>;
|
|
29
28
|
readAll(): Promise<EmailLogEntry[]>;
|
|
30
29
|
};
|
|
31
30
|
logger: {
|
|
@@ -51,7 +50,7 @@ export async function runPipeline(deps: PipelineDeps): Promise<void> {
|
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
// Auto-resolve: re-check active threads for owner replies
|
|
54
|
-
const activeEntries = digest.
|
|
53
|
+
const activeEntries = digest.getActiveEntries();
|
|
55
54
|
for (const entry of activeEntries) {
|
|
56
55
|
try {
|
|
57
56
|
const replied = await poller.checkThreadForReply(entry.threadId, entry.account);
|
|
@@ -72,11 +71,10 @@ export async function runPipeline(deps: PipelineDeps): Promise<void> {
|
|
|
72
71
|
|
|
73
72
|
for (const account of accounts) {
|
|
74
73
|
try {
|
|
75
|
-
const emails = await poller.pollAccount(account, seenIds);
|
|
74
|
+
const { emails, historyId } = await poller.pollAccount(account, seenIds);
|
|
76
75
|
const filtered = emails.filter((e) => !digest.has(e.id));
|
|
77
76
|
allNewEmails.push(...filtered);
|
|
78
|
-
|
|
79
|
-
poller.recordSuccess(account, currentState?.historyId ?? "");
|
|
77
|
+
poller.recordSuccess(account, historyId ?? poller.getAccountState(account)?.historyId ?? "");
|
|
80
78
|
logger.info(`betteremail: ${account} — ${emails.length} fetched, ${filtered.length} new`);
|
|
81
79
|
} catch (err) {
|
|
82
80
|
const failures = poller.recordFailure(account);
|
package/src/poller.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { RawGogMessage, RawGogThread, PollState, TrimmedEmail } from "./typ
|
|
|
3
3
|
import { trimEmailBody } from "./trimmer.js";
|
|
4
4
|
import * as fs from "node:fs/promises";
|
|
5
5
|
import * as path from "node:path";
|
|
6
|
+
import { atomicWrite } from "./atomic.js";
|
|
6
7
|
|
|
7
8
|
const STATE_FILE = "state.json";
|
|
8
9
|
|
|
@@ -18,6 +19,19 @@ export function parseGogMessages(stdout: string): RawGogMessage[] {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
export function extractHistoryId(stdout: string): string | undefined {
|
|
23
|
+
if (!stdout || !stdout.trim()) return undefined;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(stdout.trim());
|
|
26
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && typeof parsed.historyId === "string") {
|
|
27
|
+
return parsed.historyId;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
export function parseGogThread(stdout: string): RawGogThread | null {
|
|
22
36
|
if (!stdout || !stdout.trim()) return null;
|
|
23
37
|
try {
|
|
@@ -36,7 +50,8 @@ function extractEmail(fromField: string): string {
|
|
|
36
50
|
|
|
37
51
|
export function detectOwnerReply(thread: RawGogThread, ownerAccounts: string[]): boolean {
|
|
38
52
|
const lowerAccounts = ownerAccounts.map((a) => a.toLowerCase());
|
|
39
|
-
|
|
53
|
+
// Skip first message (could be user-initiated thread)
|
|
54
|
+
return thread.messages.slice(1).some((msg) => {
|
|
40
55
|
if (!msg.from) return false;
|
|
41
56
|
const email = extractEmail(msg.from);
|
|
42
57
|
return lowerAccounts.includes(email);
|
|
@@ -68,11 +83,9 @@ export class Poller {
|
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
async saveState(): Promise<void> {
|
|
71
|
-
await
|
|
72
|
-
await fs.writeFile(
|
|
86
|
+
await atomicWrite(
|
|
73
87
|
path.join(this.stateDir, STATE_FILE),
|
|
74
88
|
JSON.stringify(this.state, null, 2) + "\n",
|
|
75
|
-
"utf8",
|
|
76
89
|
);
|
|
77
90
|
}
|
|
78
91
|
|
|
@@ -126,15 +139,16 @@ export class Poller {
|
|
|
126
139
|
return detectOwnerReply(thread, this.accounts);
|
|
127
140
|
}
|
|
128
141
|
|
|
129
|
-
async pollAccount(account: string, seenMessageIds: Set<string>): Promise<TrimmedEmail[]> {
|
|
142
|
+
async pollAccount(account: string, seenMessageIds: Set<string>): Promise<{ emails: TrimmedEmail[]; historyId?: string }> {
|
|
130
143
|
const accountState = this.state.accounts[account];
|
|
131
|
-
const
|
|
144
|
+
const currentHistoryId = accountState?.historyId;
|
|
132
145
|
|
|
133
146
|
let messages: RawGogMessage[];
|
|
147
|
+
let newHistoryId: string | undefined;
|
|
134
148
|
|
|
135
|
-
if (
|
|
149
|
+
if (currentHistoryId) {
|
|
136
150
|
const result = await this.runGog([
|
|
137
|
-
"gmail", "history", "--since",
|
|
151
|
+
"gmail", "history", "--since", currentHistoryId, "--account", account, "--json",
|
|
138
152
|
]);
|
|
139
153
|
if (!result.ok) {
|
|
140
154
|
this.api.logger.info(`betteremail: history fetch failed for ${account}, falling back to rescan`);
|
|
@@ -142,9 +156,11 @@ export class Poller {
|
|
|
142
156
|
"gmail", "messages", "search", `newer_than:${this.rescanDays}d`,
|
|
143
157
|
"--account", account, "--json", "--include-body",
|
|
144
158
|
]);
|
|
145
|
-
if (!fallback.ok) return [];
|
|
159
|
+
if (!fallback.ok) return { emails: [] };
|
|
146
160
|
messages = parseGogMessages(fallback.stdout);
|
|
161
|
+
// No historyId from search fallback
|
|
147
162
|
} else {
|
|
163
|
+
newHistoryId = extractHistoryId(result.stdout);
|
|
148
164
|
messages = parseGogMessages(result.stdout);
|
|
149
165
|
}
|
|
150
166
|
} else {
|
|
@@ -152,8 +168,9 @@ export class Poller {
|
|
|
152
168
|
"gmail", "messages", "search", `newer_than:${this.rescanDays}d`,
|
|
153
169
|
"--account", account, "--json", "--include-body",
|
|
154
170
|
]);
|
|
155
|
-
if (!result.ok) return [];
|
|
171
|
+
if (!result.ok) return { emails: [] };
|
|
156
172
|
messages = parseGogMessages(result.stdout);
|
|
173
|
+
// No historyId from initial search
|
|
157
174
|
}
|
|
158
175
|
|
|
159
176
|
const newMessages = messages.filter((m) => !seenMessageIds.has(m.id));
|
|
@@ -184,6 +201,6 @@ export class Poller {
|
|
|
184
201
|
});
|
|
185
202
|
}
|
|
186
203
|
|
|
187
|
-
return trimmedEmails;
|
|
204
|
+
return { emails: trimmedEmails, historyId: newHistoryId };
|
|
188
205
|
}
|
|
189
206
|
}
|
package/src/tools/defer-email.ts
CHANGED
|
@@ -13,12 +13,21 @@ export function createDeferEmailTool(digest: DigestManager) {
|
|
|
13
13
|
minutes: Type.Number({ description: "Minutes until the email re-surfaces in the digest" }),
|
|
14
14
|
}),
|
|
15
15
|
async execute(_id: string, params: Record<string, unknown>) {
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
if (typeof params.messageId !== "string" || !params.messageId) {
|
|
17
|
+
return { content: [{ type: "text" as const, text: "Error: messageId must be a non-empty string." }] };
|
|
18
|
+
}
|
|
19
|
+
if (typeof params.minutes !== "number" || params.minutes <= 0 || !Number.isFinite(params.minutes)) {
|
|
20
|
+
return { content: [{ type: "text" as const, text: "Error: minutes must be a positive number." }] };
|
|
21
|
+
}
|
|
22
|
+
const messageId = params.messageId;
|
|
23
|
+
const minutes = params.minutes;
|
|
18
24
|
const entry = digest.get(messageId);
|
|
19
25
|
if (!entry) {
|
|
20
26
|
return { content: [{ type: "text" as const, text: `Email ${messageId} not found in digest.` }] };
|
|
21
27
|
}
|
|
28
|
+
if (entry.status === "handled" || entry.status === "dismissed" || entry.status === "deferred") {
|
|
29
|
+
return { content: [{ type: "text" as const, text: `Cannot defer: email is already "${entry.status}".` }] };
|
|
30
|
+
}
|
|
22
31
|
digest.defer(messageId, minutes);
|
|
23
32
|
await digest.save();
|
|
24
33
|
return {
|
|
@@ -11,12 +11,19 @@ export function createDismissEmailTool(digest: DigestManager) {
|
|
|
11
11
|
reason: Type.Optional(Type.String({ description: "Optional reason for dismissing" })),
|
|
12
12
|
}),
|
|
13
13
|
async execute(_id: string, params: Record<string, unknown>) {
|
|
14
|
-
|
|
14
|
+
if (typeof params.messageId !== "string" || !params.messageId) {
|
|
15
|
+
return { content: [{ type: "text" as const, text: "Error: messageId must be a non-empty string." }] };
|
|
16
|
+
}
|
|
17
|
+
const messageId = params.messageId;
|
|
18
|
+
const reason = typeof params.reason === "string" ? params.reason : undefined;
|
|
15
19
|
const entry = digest.get(messageId);
|
|
16
20
|
if (!entry) {
|
|
17
21
|
return { content: [{ type: "text" as const, text: `Email ${messageId} not found in digest.` }] };
|
|
18
22
|
}
|
|
19
|
-
|
|
23
|
+
if (entry.status === "handled" || entry.status === "dismissed") {
|
|
24
|
+
return { content: [{ type: "text" as const, text: `Cannot dismiss: email is already "${entry.status}".` }] };
|
|
25
|
+
}
|
|
26
|
+
digest.dismiss(messageId, reason);
|
|
20
27
|
await digest.save();
|
|
21
28
|
return {
|
|
22
29
|
content: [{ type: "text" as const, text: `Dismissed "${entry.subject}" from ${entry.from}. It won't be flagged again.` }],
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
2
|
import type { DigestManager } from "../digest.js";
|
|
3
3
|
import type { DigestStatus } from "../types.js";
|
|
4
|
+
import { formatAge } from "../utils.js";
|
|
4
5
|
|
|
5
6
|
export function createGetEmailDigestTool(digest: DigestManager) {
|
|
6
7
|
return {
|
|
@@ -21,7 +22,11 @@ export function createGetEmailDigestTool(digest: DigestManager) {
|
|
|
21
22
|
),
|
|
22
23
|
}),
|
|
23
24
|
async execute(_id: string, params: Record<string, unknown>) {
|
|
25
|
+
const validStatuses = ["new", "surfaced", "deferred", "all"];
|
|
24
26
|
const status = (typeof params.status === "string" ? params.status : "new") as DigestStatus | "all";
|
|
27
|
+
if (!validStatuses.includes(status)) {
|
|
28
|
+
return { content: [{ type: "text" as const, text: `Error: status must be one of: ${validStatuses.join(", ")}` }] };
|
|
29
|
+
}
|
|
25
30
|
const account = typeof params.account === "string" ? params.account : undefined;
|
|
26
31
|
|
|
27
32
|
let grouped = digest.getGroupedByAccount(status);
|
|
@@ -30,14 +35,7 @@ export function createGetEmailDigestTool(digest: DigestManager) {
|
|
|
30
35
|
grouped = { [account]: grouped[account] ?? [] };
|
|
31
36
|
}
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
for (const entry of entries) {
|
|
35
|
-
if (entry.status === "new") {
|
|
36
|
-
digest.markSurfaced(entry.id);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
38
|
+
// Build response first (while status is still "new")
|
|
41
39
|
const summary: Record<string, unknown[]> = {};
|
|
42
40
|
for (const [acc, entries] of Object.entries(grouped)) {
|
|
43
41
|
summary[acc] = entries.map((e) => ({
|
|
@@ -54,6 +52,15 @@ export function createGetEmailDigestTool(digest: DigestManager) {
|
|
|
54
52
|
}));
|
|
55
53
|
}
|
|
56
54
|
|
|
55
|
+
// THEN mark as surfaced
|
|
56
|
+
for (const entries of Object.values(grouped)) {
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (entry.status === "new") {
|
|
59
|
+
digest.markSurfaced(entry.id);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
await digest.save();
|
|
58
65
|
|
|
59
66
|
return {
|
|
@@ -62,13 +69,3 @@ export function createGetEmailDigestTool(digest: DigestManager) {
|
|
|
62
69
|
},
|
|
63
70
|
};
|
|
64
71
|
}
|
|
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
|
-
}
|
|
@@ -10,11 +10,17 @@ export function createMarkEmailHandledTool(digest: DigestManager) {
|
|
|
10
10
|
messageId: Type.String({ description: "The message ID to mark as handled" }),
|
|
11
11
|
}),
|
|
12
12
|
async execute(_id: string, params: Record<string, unknown>) {
|
|
13
|
-
|
|
13
|
+
if (typeof params.messageId !== "string" || !params.messageId) {
|
|
14
|
+
return { content: [{ type: "text" as const, text: "Error: messageId must be a non-empty string." }] };
|
|
15
|
+
}
|
|
16
|
+
const messageId = params.messageId;
|
|
14
17
|
const entry = digest.get(messageId);
|
|
15
18
|
if (!entry) {
|
|
16
19
|
return { content: [{ type: "text" as const, text: `Email ${messageId} not found in digest.` }] };
|
|
17
20
|
}
|
|
21
|
+
if (entry.status === "handled" || entry.status === "dismissed") {
|
|
22
|
+
return { content: [{ type: "text" as const, text: `Cannot mark as handled: email is already "${entry.status}".` }] };
|
|
23
|
+
}
|
|
18
24
|
digest.markHandled(messageId);
|
|
19
25
|
await digest.save();
|
|
20
26
|
return {
|
package/src/types.ts
CHANGED
package/src/utils.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function formatAge(isoDate: string): string {
|
|
2
|
+
const ms = Date.now() - new Date(isoDate).getTime();
|
|
3
|
+
const minutes = Math.floor(ms / 60_000);
|
|
4
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
5
|
+
const hours = Math.floor(minutes / 60);
|
|
6
|
+
if (hours < 24) return `${hours}h ago`;
|
|
7
|
+
const days = Math.floor(hours / 24);
|
|
8
|
+
return `${days}d ago`;
|
|
9
|
+
}
|