pi-protonmail 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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/biome.json +25 -0
- package/package.json +63 -0
- package/src/constants.ts +1 -0
- package/src/hub.ts +559 -0
- package/src/index.ts +7 -0
- package/src/proton-bridge.ts +444 -0
- package/src/protonmail.ts +624 -0
- package/src/secret-refs.ts +81 -0
- package/src/types.ts +90 -0
- package/src/workspace.ts +96 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Theme } from "@earendil-works/pi-tui";
|
|
6
|
+
import { Markdown, Text } from "@earendil-works/pi-tui";
|
|
7
|
+
import { Data, Effect } from "effect";
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
|
|
10
|
+
import { PREVIEW_LINES } from "./constants.ts";
|
|
11
|
+
import { openProtonMailHub } from "./hub.ts";
|
|
12
|
+
import {
|
|
13
|
+
protonBridgeImportAttachments as runProtonBridgeImportAttachments,
|
|
14
|
+
protonBridgeListMailboxes as runProtonBridgeListMailboxes,
|
|
15
|
+
protonBridgeListMessages as runProtonBridgeListMessages,
|
|
16
|
+
protonBridgeStatus as runProtonBridgeStatus,
|
|
17
|
+
} from "./proton-bridge.ts";
|
|
18
|
+
import { resolveSecretReference } from "./secret-refs.ts";
|
|
19
|
+
import type {
|
|
20
|
+
BridgeStatusResult,
|
|
21
|
+
CommandContext,
|
|
22
|
+
MailboxListResult,
|
|
23
|
+
MessageListResult,
|
|
24
|
+
ProtonBridgeConfig,
|
|
25
|
+
ProtonMailWorkingProfile,
|
|
26
|
+
ToolContext,
|
|
27
|
+
} from "./types.ts";
|
|
28
|
+
import {
|
|
29
|
+
deleteProtonMailProfile,
|
|
30
|
+
listProtonMailProfiles,
|
|
31
|
+
normalizeProtonMailProfile,
|
|
32
|
+
protonMailProfilePolicyPath,
|
|
33
|
+
readProtonMailProfilePolicy,
|
|
34
|
+
readProtonMailWorkspaceConfig,
|
|
35
|
+
writeProtonMailProfilePolicy,
|
|
36
|
+
writeProtonMailWorkspaceConfig,
|
|
37
|
+
} from "./workspace.ts";
|
|
38
|
+
|
|
39
|
+
class ProtonCommandError extends Data.TaggedError("ProtonCommandError")<{
|
|
40
|
+
message: string;
|
|
41
|
+
}> {}
|
|
42
|
+
|
|
43
|
+
function toProtonCommandError(error: unknown): ProtonCommandError {
|
|
44
|
+
return new ProtonCommandError({
|
|
45
|
+
message: error instanceof Error ? error.message : String(error),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function runProtonBoundary(ctx: CommandContext, effect: Effect.Effect<void, ProtonCommandError>) {
|
|
50
|
+
return Effect.runPromise(
|
|
51
|
+
Effect.catchAll(effect, (error) =>
|
|
52
|
+
Effect.sync(() => {
|
|
53
|
+
ctx.ui.notify(error.message, "error");
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function effectFromProtonPromise<T>(thunk: () => Promise<T>): Effect.Effect<T, ProtonCommandError> {
|
|
60
|
+
return Effect.tryPromise({ try: thunk, catch: toProtonCommandError });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderPreview(text: string, expanded: boolean, _theme: Theme) {
|
|
64
|
+
const mdTheme = getMarkdownTheme();
|
|
65
|
+
if (expanded) return new Markdown(text, 0, 0, mdTheme);
|
|
66
|
+
|
|
67
|
+
const lines = text.split("\n");
|
|
68
|
+
if (lines.length <= PREVIEW_LINES) return new Markdown(text, 0, 0, mdTheme);
|
|
69
|
+
const preview = lines.slice(0, PREVIEW_LINES).join("\n");
|
|
70
|
+
return new Markdown(`${preview}\n… ${lines.length - PREVIEW_LINES} more lines`, 0, 0, mdTheme);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function renderToolResult(
|
|
74
|
+
result: { content?: Array<{ type: string; text?: string }> },
|
|
75
|
+
options: { expanded: boolean },
|
|
76
|
+
theme: Theme,
|
|
77
|
+
) {
|
|
78
|
+
const text = result.content?.[0]?.type === "text" ? (result.content[0].text ?? "") : "";
|
|
79
|
+
return renderPreview(text || "(empty)", options.expanded, theme);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function trimText(text: string, maxLines = 120, maxChars = 12000): string {
|
|
83
|
+
const lines = text.split("\n");
|
|
84
|
+
let trimmed = lines.slice(0, maxLines).join("\n");
|
|
85
|
+
if (trimmed.length > maxChars) trimmed = `${trimmed.slice(0, maxChars)}\n… output truncated`;
|
|
86
|
+
if (lines.length > maxLines) trimmed += `\n… ${lines.length - maxLines} more lines`;
|
|
87
|
+
return trimmed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseMonthPeriod(value?: string): string | undefined {
|
|
91
|
+
if (!value) return undefined;
|
|
92
|
+
return /^\d{4}-\d{2}$/.test(value) ? value : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function getProtonBridgeConfig(defaultMailbox?: string): Promise<ProtonBridgeConfig> {
|
|
96
|
+
const host = process.env.PROTON_BRIDGE_HOST?.trim() || "127.0.0.1";
|
|
97
|
+
const imapPort = Number.parseInt(process.env.PROTON_BRIDGE_IMAP_PORT?.trim() || "1143", 10);
|
|
98
|
+
const smtpPort = Number.parseInt(process.env.PROTON_BRIDGE_SMTP_PORT?.trim() || "1025", 10);
|
|
99
|
+
const security = process.env.PROTON_BRIDGE_IMAP_SECURITY?.trim() || "starttls";
|
|
100
|
+
const envDefaultMailbox = process.env.PROTON_BRIDGE_DEFAULT_MAILBOX?.trim() || undefined;
|
|
101
|
+
const rawUsername = process.env.PROTON_BRIDGE_USERNAME?.trim() || undefined;
|
|
102
|
+
const rawPassword = process.env.PROTON_BRIDGE_PASSWORD?.trim() || undefined;
|
|
103
|
+
const username = rawUsername
|
|
104
|
+
? await resolveSecretReference(rawUsername, "Proton Bridge username")
|
|
105
|
+
: undefined;
|
|
106
|
+
const password = rawPassword
|
|
107
|
+
? await resolveSecretReference(rawPassword, "Proton Bridge password")
|
|
108
|
+
: undefined;
|
|
109
|
+
return {
|
|
110
|
+
host,
|
|
111
|
+
imapPort,
|
|
112
|
+
smtpPort,
|
|
113
|
+
username,
|
|
114
|
+
password,
|
|
115
|
+
security,
|
|
116
|
+
defaultMailbox: defaultMailbox ?? envDefaultMailbox,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function protonMailSetupHint(profile?: string): string {
|
|
121
|
+
const profileHint = profile ? ` for profile \`${profile}\`` : "";
|
|
122
|
+
return [
|
|
123
|
+
`Proton Mail setup${profileHint} is not fully configured.`,
|
|
124
|
+
"",
|
|
125
|
+
"Add these variables to .env before starting Pi:",
|
|
126
|
+
"- PROTON_BRIDGE_HOST=127.0.0.1",
|
|
127
|
+
"- PROTON_BRIDGE_IMAP_PORT=1143",
|
|
128
|
+
"- PROTON_BRIDGE_SMTP_PORT=1025",
|
|
129
|
+
"- PROTON_BRIDGE_IMAP_SECURITY=starttls",
|
|
130
|
+
"- PROTON_BRIDGE_USERNAME=<Bridge mailbox username, `op://...`, or literal `op read ...` / `$(op read ...)`>",
|
|
131
|
+
"- PROTON_BRIDGE_PASSWORD=<Bridge mailbox password, `op://...`, or literal `op read ...` / `$(op read ...)`>",
|
|
132
|
+
"- optional: PROTON_BRIDGE_DEFAULT_MAILBOX=<mailbox name such as All Mail>",
|
|
133
|
+
"",
|
|
134
|
+
"Use `/protonmail` to edit profile defaults and the `protonmail_*` tools to inspect Bridge status, mailboxes, and message imports.",
|
|
135
|
+
].join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveProtonMailImportWorkspaceRoot(profile: string, policyRoot?: string): string {
|
|
139
|
+
return policyRoot?.trim() || join(".pi", "protonmail", "imports", profile);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function listProtonMailWorkingProfiles(cwd: string): Promise<ProtonMailWorkingProfile[]> {
|
|
143
|
+
const profiles = new Map<string, ProtonMailWorkingProfile>();
|
|
144
|
+
for (const profile of await listProtonMailProfiles(cwd)) {
|
|
145
|
+
profiles.set(profile, {
|
|
146
|
+
profile,
|
|
147
|
+
policy: await readProtonMailProfilePolicy(cwd, profile),
|
|
148
|
+
policyPath: protonMailProfilePolicyPath(profile, cwd),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const defaultProfile = normalizeProtonMailProfile("default");
|
|
153
|
+
if (!profiles.has(defaultProfile)) {
|
|
154
|
+
profiles.set(defaultProfile, {
|
|
155
|
+
profile: defaultProfile,
|
|
156
|
+
policy: await readProtonMailProfilePolicy(cwd, defaultProfile),
|
|
157
|
+
policyPath: protonMailProfilePolicyPath(defaultProfile, cwd),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return [...profiles.values()].sort((left, right) => left.profile.localeCompare(right.profile));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function resolveProtonMailActiveProfile(
|
|
165
|
+
cwd: string,
|
|
166
|
+
explicitProfile?: string,
|
|
167
|
+
): Promise<ProtonMailWorkingProfile> {
|
|
168
|
+
const profiles = await listProtonMailWorkingProfiles(cwd);
|
|
169
|
+
const normalizedExplicit = explicitProfile?.trim()
|
|
170
|
+
? normalizeProtonMailProfile(explicitProfile)
|
|
171
|
+
: undefined;
|
|
172
|
+
if (normalizedExplicit) {
|
|
173
|
+
const explicit = profiles.find((profile) => profile.profile === normalizedExplicit);
|
|
174
|
+
if (explicit) return explicit;
|
|
175
|
+
return {
|
|
176
|
+
profile: normalizedExplicit,
|
|
177
|
+
policy: await readProtonMailProfilePolicy(cwd, normalizedExplicit),
|
|
178
|
+
policyPath: protonMailProfilePolicyPath(normalizedExplicit, cwd),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const workspace = await readProtonMailWorkspaceConfig(cwd);
|
|
183
|
+
const activeProfile = normalizeProtonMailProfile(workspace.activeProfile);
|
|
184
|
+
return profiles.find((profile) => profile.profile === activeProfile) ?? profiles[0];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function protonBridgeStatus(
|
|
188
|
+
_cwd: string,
|
|
189
|
+
defaultMailbox?: string,
|
|
190
|
+
): Promise<BridgeStatusResult> {
|
|
191
|
+
const config = await getProtonBridgeConfig(defaultMailbox);
|
|
192
|
+
return runProtonBridgeStatus(config);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function listProtonMailboxes(
|
|
196
|
+
_cwd: string,
|
|
197
|
+
query?: string,
|
|
198
|
+
defaultMailbox?: string,
|
|
199
|
+
): Promise<MailboxListResult> {
|
|
200
|
+
const config = await getProtonBridgeConfig(defaultMailbox);
|
|
201
|
+
if (!config.username || !config.password) throw new Error(protonMailSetupHint(defaultMailbox));
|
|
202
|
+
return runProtonBridgeListMailboxes(config, query);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function listProtonMessages(
|
|
206
|
+
_cwd: string,
|
|
207
|
+
mailbox?: string,
|
|
208
|
+
period?: string,
|
|
209
|
+
query?: string,
|
|
210
|
+
unseenOnly = false,
|
|
211
|
+
limit = 20,
|
|
212
|
+
defaultMailbox?: string,
|
|
213
|
+
): Promise<MessageListResult> {
|
|
214
|
+
const config = await getProtonBridgeConfig(defaultMailbox);
|
|
215
|
+
if (!config.username || !config.password) throw new Error(protonMailSetupHint(defaultMailbox));
|
|
216
|
+
return runProtonBridgeListMessages(config, mailbox, period, query, unseenOnly, limit);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function formatStatusSummary(result: BridgeStatusResult): string {
|
|
220
|
+
const lines = [
|
|
221
|
+
"# Proton Bridge Status",
|
|
222
|
+
"",
|
|
223
|
+
`- Host: \`${result.config.host}\``,
|
|
224
|
+
`- IMAP port: \`${result.config.imap_port}\``,
|
|
225
|
+
`- SMTP port: \`${result.config.smtp_port}\``,
|
|
226
|
+
`- IMAP security: \`${result.config.security}\``,
|
|
227
|
+
`- Default mailbox: ${result.config.default_mailbox ? `\`${result.config.default_mailbox}\`` : "—"}`,
|
|
228
|
+
`- Username configured: ${result.config.username_set ? "yes" : "no"}`,
|
|
229
|
+
`- Password configured: ${result.config.password_set ? "yes" : "no"}`,
|
|
230
|
+
"",
|
|
231
|
+
"## Local ports",
|
|
232
|
+
"",
|
|
233
|
+
`- IMAP: ${result.imap.open ? "open" : `closed (${result.imap.error ?? "unknown error"})`}`,
|
|
234
|
+
result.imap.banner
|
|
235
|
+
? `- IMAP banner: \`${result.imap.banner.replace(/`/g, "'")}\``
|
|
236
|
+
: "- IMAP banner: —",
|
|
237
|
+
`- SMTP: ${result.smtp.open ? "open" : `closed (${result.smtp.error ?? "unknown error"})`}`,
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
if (result.login) {
|
|
241
|
+
lines.push("", "## Login check", "");
|
|
242
|
+
if (result.login.ok) {
|
|
243
|
+
lines.push(`- Login: ok`, `- Mailboxes visible: ${result.login.mailbox_count ?? 0}`);
|
|
244
|
+
(result.login.mailboxes ?? []).slice(0, 5).forEach((mailbox) => {
|
|
245
|
+
lines.push(`- ${mailbox.name}`);
|
|
246
|
+
});
|
|
247
|
+
} else {
|
|
248
|
+
lines.push(`- Login: failed`, `- Error: ${result.login.error ?? "unknown error"}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!result.config.username_set || !result.config.password_set) {
|
|
253
|
+
lines.push("", protonMailSetupHint());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return lines.join("\n");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function formatMailboxSummary(result: MailboxListResult, query?: string): string {
|
|
260
|
+
const lines = [
|
|
261
|
+
query ? `# Proton Bridge Mailboxes matching ${query}` : "# Proton Bridge Mailboxes",
|
|
262
|
+
"",
|
|
263
|
+
`- Count: ${result.count}`,
|
|
264
|
+
"",
|
|
265
|
+
];
|
|
266
|
+
result.mailboxes.forEach((mailbox) => {
|
|
267
|
+
const flags = mailbox.flags?.length ? ` (${mailbox.flags.join(", ")})` : "";
|
|
268
|
+
lines.push(`- ${mailbox.name}${flags}`);
|
|
269
|
+
});
|
|
270
|
+
return lines.join("\n");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function formatMessageSummary(result: MessageListResult, period?: string): string {
|
|
274
|
+
const lines = [
|
|
275
|
+
`# Proton Bridge Messages — ${result.mailbox}${period ? ` ${period}` : ""}`,
|
|
276
|
+
"",
|
|
277
|
+
`- Matching messages: ${result.count}`,
|
|
278
|
+
"",
|
|
279
|
+
"| UID | Date | From | Subject | Attachments |",
|
|
280
|
+
"|---|---|---|---|---|",
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
result.messages.forEach((message) => {
|
|
284
|
+
const attachments =
|
|
285
|
+
message.attachments.length > 0
|
|
286
|
+
? message.attachments
|
|
287
|
+
.map((attachment) => attachment.filename.replace(/\|/g, " "))
|
|
288
|
+
.join(", ")
|
|
289
|
+
: "—";
|
|
290
|
+
lines.push(
|
|
291
|
+
`| ${message.uid} | ${(message.date ?? "—").replace(/\|/g, " ")} | ${(message.from ?? "—").replace(/\|/g, " ")} | ${(message.subject ?? "—").replace(/\|/g, " ")} | ${attachments} |`,
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
if (result.messages.length === 0) lines.push("| — | — | — | — | — |");
|
|
296
|
+
return lines.join("\n");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatImportSummary(
|
|
300
|
+
result: {
|
|
301
|
+
workspace_root: string;
|
|
302
|
+
period_root: string;
|
|
303
|
+
mail_root: string;
|
|
304
|
+
inbox_root: string;
|
|
305
|
+
mailbox: string;
|
|
306
|
+
profile: string;
|
|
307
|
+
message_count: number;
|
|
308
|
+
attachment_count: number;
|
|
309
|
+
messages: Array<{
|
|
310
|
+
uid: string;
|
|
311
|
+
subject?: string;
|
|
312
|
+
from?: string;
|
|
313
|
+
date?: string;
|
|
314
|
+
raw_path: string;
|
|
315
|
+
attachment_count: number;
|
|
316
|
+
attachments: Array<{
|
|
317
|
+
filename: string;
|
|
318
|
+
mail_path: string;
|
|
319
|
+
inbox_path: string;
|
|
320
|
+
content_type?: string;
|
|
321
|
+
size?: number;
|
|
322
|
+
}>;
|
|
323
|
+
}>;
|
|
324
|
+
},
|
|
325
|
+
period: string,
|
|
326
|
+
): string {
|
|
327
|
+
const lines = [
|
|
328
|
+
`# Proton Mail import — ${result.profile} ${period}`,
|
|
329
|
+
"",
|
|
330
|
+
`- Mailbox: \`${result.mailbox}\``,
|
|
331
|
+
`- Workspace root: \`${result.workspace_root}\``,
|
|
332
|
+
`- Period root: \`${result.period_root}\``,
|
|
333
|
+
`- Mail staging: \`${result.mail_root}\``,
|
|
334
|
+
`- Inbox staging: \`${result.inbox_root}\``,
|
|
335
|
+
`- Imported messages: ${result.message_count}`,
|
|
336
|
+
`- Imported attachments: ${result.attachment_count}`,
|
|
337
|
+
"",
|
|
338
|
+
];
|
|
339
|
+
|
|
340
|
+
for (const message of result.messages) {
|
|
341
|
+
lines.push(`## UID ${message.uid}`, "");
|
|
342
|
+
lines.push(`- Subject: ${message.subject ?? "—"}`);
|
|
343
|
+
lines.push(`- From: ${message.from ?? "—"}`);
|
|
344
|
+
lines.push(`- Date: ${message.date ?? "—"}`);
|
|
345
|
+
lines.push(`- Raw email: \`${message.raw_path}\``);
|
|
346
|
+
for (const attachment of message.attachments) {
|
|
347
|
+
lines.push(
|
|
348
|
+
`- Attachment: \`${attachment.inbox_path}\` (${attachment.size ?? 0} B, ${attachment.content_type ?? "application/octet-stream"})`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
lines.push("");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (result.message_count === 0)
|
|
355
|
+
lines.push("No matching attachment-bearing messages were imported.");
|
|
356
|
+
return lines.join("\n");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export default function registerProtonBridgeExtension(pi: ExtensionAPI) {
|
|
360
|
+
pi.registerMessageRenderer(
|
|
361
|
+
"protonmail-report",
|
|
362
|
+
(message: { content: string }, { expanded }: { expanded: boolean }, theme: Theme) =>
|
|
363
|
+
renderPreview(message.content, expanded, theme),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
pi.registerCommand("protonmail", {
|
|
367
|
+
description: "Open the Proton Mail setup hub",
|
|
368
|
+
handler: async (args: string, ctx: CommandContext) =>
|
|
369
|
+
runProtonBoundary(
|
|
370
|
+
ctx,
|
|
371
|
+
Effect.gen(function* () {
|
|
372
|
+
const profiles = yield* effectFromProtonPromise(() =>
|
|
373
|
+
listProtonMailWorkingProfiles(ctx.cwd),
|
|
374
|
+
);
|
|
375
|
+
const activeProfile = yield* effectFromProtonPromise(() =>
|
|
376
|
+
resolveProtonMailActiveProfile(ctx.cwd),
|
|
377
|
+
);
|
|
378
|
+
const initialArgs = args.trim() ? args : activeProfile.profile;
|
|
379
|
+
const result = yield* effectFromProtonPromise(() =>
|
|
380
|
+
openProtonMailHub(ctx, profiles, initialArgs),
|
|
381
|
+
);
|
|
382
|
+
if (!result) return;
|
|
383
|
+
yield* effectFromProtonPromise(async () => {
|
|
384
|
+
if (result.kind === "save") {
|
|
385
|
+
await writeProtonMailProfilePolicy(ctx.cwd, result.profile, result.policy);
|
|
386
|
+
await writeProtonMailWorkspaceConfig(ctx.cwd, { activeProfile: result.profile });
|
|
387
|
+
ctx.ui.notify(`Saved Proton Mail profile ${result.profile}`, "info");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
await deleteProtonMailProfile(ctx.cwd, result.profile);
|
|
391
|
+
const remaining = profiles.filter((profile) => profile.profile !== result.profile);
|
|
392
|
+
const fallback = remaining[0]?.profile ?? "default";
|
|
393
|
+
await writeProtonMailWorkspaceConfig(ctx.cwd, { activeProfile: fallback });
|
|
394
|
+
ctx.ui.notify(`Deleted Proton Mail profile ${result.profile}`, "info");
|
|
395
|
+
});
|
|
396
|
+
}),
|
|
397
|
+
),
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
pi.registerTool({
|
|
401
|
+
name: "protonmail_bridge_status",
|
|
402
|
+
label: "ProtonMail Bridge Status",
|
|
403
|
+
description: "Check Proton Bridge host/ports, credential presence, and IMAP login health",
|
|
404
|
+
promptSnippet: "Check whether Proton Bridge is reachable and configured before reading mail",
|
|
405
|
+
promptGuidelines: [
|
|
406
|
+
"Use this tool before listing mailboxes or importing attachments from Proton Bridge.",
|
|
407
|
+
"Bridge exposes local IMAP/SMTP ports; this tool verifies the repo-side config and whether login succeeds.",
|
|
408
|
+
],
|
|
409
|
+
parameters: Type.Object({}),
|
|
410
|
+
async execute(
|
|
411
|
+
_id: string,
|
|
412
|
+
_params: Record<string, never>,
|
|
413
|
+
_signal: AbortSignal,
|
|
414
|
+
_onUpdate: unknown,
|
|
415
|
+
ctx: ToolContext,
|
|
416
|
+
) {
|
|
417
|
+
const profile = await resolveProtonMailActiveProfile(ctx.cwd);
|
|
418
|
+
const result = await protonBridgeStatus(ctx.cwd, profile.policy.default_mailbox);
|
|
419
|
+
return {
|
|
420
|
+
content: [{ type: "text", text: trimText(formatStatusSummary(result), 160, 16000) }],
|
|
421
|
+
details: result,
|
|
422
|
+
};
|
|
423
|
+
},
|
|
424
|
+
renderCall(_args: Record<string, never>, theme: Theme) {
|
|
425
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("protonmail_bridge_status"))}`, 0, 0);
|
|
426
|
+
},
|
|
427
|
+
renderResult: renderToolResult,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
pi.registerTool({
|
|
431
|
+
name: "protonmail_list_mailboxes",
|
|
432
|
+
label: "ProtonMail List Mailboxes",
|
|
433
|
+
description: "List Proton Bridge mailboxes available through local IMAP",
|
|
434
|
+
promptSnippet: "List Proton Bridge mailboxes before choosing one",
|
|
435
|
+
promptGuidelines: [
|
|
436
|
+
"Use this tool after protonmail_bridge_status when you need the exact mailbox name.",
|
|
437
|
+
"Mailbox names come from Proton Bridge IMAP and may differ from UI labels if the account language changes.",
|
|
438
|
+
],
|
|
439
|
+
parameters: Type.Object({
|
|
440
|
+
query: Type.Optional(Type.String({ description: "Optional mailbox substring filter" })),
|
|
441
|
+
}),
|
|
442
|
+
async execute(
|
|
443
|
+
_id: string,
|
|
444
|
+
params: { query?: string },
|
|
445
|
+
_signal: AbortSignal,
|
|
446
|
+
_onUpdate: unknown,
|
|
447
|
+
ctx: ToolContext,
|
|
448
|
+
) {
|
|
449
|
+
const profile = await resolveProtonMailActiveProfile(ctx.cwd);
|
|
450
|
+
const query = params.query?.trim() || profile.policy.mailbox_filter;
|
|
451
|
+
const result = await listProtonMailboxes(ctx.cwd, query, profile.policy.default_mailbox);
|
|
452
|
+
return {
|
|
453
|
+
content: [
|
|
454
|
+
{ type: "text", text: trimText(formatMailboxSummary(result, query), 160, 16000) },
|
|
455
|
+
],
|
|
456
|
+
details: result,
|
|
457
|
+
};
|
|
458
|
+
},
|
|
459
|
+
renderCall(args: { query?: string }, theme: Theme) {
|
|
460
|
+
return new Text(
|
|
461
|
+
`${theme.fg("toolTitle", theme.bold("protonmail_list_mailboxes "))}${theme.fg("dim", args.query ?? "all")}`,
|
|
462
|
+
0,
|
|
463
|
+
0,
|
|
464
|
+
);
|
|
465
|
+
},
|
|
466
|
+
renderResult: renderToolResult,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
pi.registerTool({
|
|
470
|
+
name: "protonmail_list_messages",
|
|
471
|
+
label: "ProtonMail List Messages",
|
|
472
|
+
description: "List recent attachment-bearing messages from a Proton Bridge mailbox",
|
|
473
|
+
promptSnippet: "Preview Proton Bridge messages before importing files",
|
|
474
|
+
promptGuidelines: [
|
|
475
|
+
"Pass an explicit mailbox or rely on the active profile default mailbox.",
|
|
476
|
+
"Use period to narrow the scan to one month such as 2026-04.",
|
|
477
|
+
],
|
|
478
|
+
parameters: Type.Object({
|
|
479
|
+
mailbox: Type.Optional(
|
|
480
|
+
Type.String({
|
|
481
|
+
description: "Mailbox name; defaults to the active profile mailbox if set",
|
|
482
|
+
}),
|
|
483
|
+
),
|
|
484
|
+
period: Type.Optional(Type.String({ description: "Optional month filter such as 2026-04" })),
|
|
485
|
+
query: Type.Optional(
|
|
486
|
+
Type.String({ description: "Optional subject/from/attachment substring filter" }),
|
|
487
|
+
),
|
|
488
|
+
unseenOnly: Type.Optional(Type.Boolean({ description: "If true, limit to unseen messages" })),
|
|
489
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of messages to return" })),
|
|
490
|
+
}),
|
|
491
|
+
async execute(
|
|
492
|
+
_id: string,
|
|
493
|
+
params: {
|
|
494
|
+
mailbox?: string;
|
|
495
|
+
period?: string;
|
|
496
|
+
query?: string;
|
|
497
|
+
unseenOnly?: boolean;
|
|
498
|
+
limit?: number;
|
|
499
|
+
},
|
|
500
|
+
_signal: AbortSignal,
|
|
501
|
+
_onUpdate: unknown,
|
|
502
|
+
ctx: ToolContext,
|
|
503
|
+
) {
|
|
504
|
+
const profile = await resolveProtonMailActiveProfile(ctx.cwd);
|
|
505
|
+
const period = params.period ? parseMonthPeriod(params.period) : undefined;
|
|
506
|
+
if (params.period && !period)
|
|
507
|
+
throw new Error(`Invalid period \`${params.period}\`. Expected YYYY-MM.`);
|
|
508
|
+
const resolvedPeriod = period ?? profile.policy.default_period;
|
|
509
|
+
const query = params.query?.trim() || profile.policy.mailbox_filter;
|
|
510
|
+
const result = await listProtonMessages(
|
|
511
|
+
ctx.cwd,
|
|
512
|
+
params.mailbox,
|
|
513
|
+
resolvedPeriod,
|
|
514
|
+
query,
|
|
515
|
+
params.unseenOnly ?? false,
|
|
516
|
+
params.limit ?? 20,
|
|
517
|
+
profile.policy.default_mailbox,
|
|
518
|
+
);
|
|
519
|
+
return {
|
|
520
|
+
content: [
|
|
521
|
+
{
|
|
522
|
+
type: "text",
|
|
523
|
+
text: trimText(formatMessageSummary(result, resolvedPeriod), 160, 16000),
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
details: result,
|
|
527
|
+
};
|
|
528
|
+
},
|
|
529
|
+
renderCall(args: { mailbox?: string; period?: string }, theme: Theme) {
|
|
530
|
+
return new Text(
|
|
531
|
+
`${theme.fg("toolTitle", theme.bold("protonmail_list_messages "))}${theme.fg("dim", `${args.mailbox ?? "default mailbox"}${args.period ? ` ${args.period}` : ""}`)}`,
|
|
532
|
+
0,
|
|
533
|
+
0,
|
|
534
|
+
);
|
|
535
|
+
},
|
|
536
|
+
renderResult: renderToolResult,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
pi.registerTool({
|
|
540
|
+
name: "protonmail_import_attachments",
|
|
541
|
+
label: "ProtonMail Import Attachments",
|
|
542
|
+
description: "Stage attachment-bearing Proton Bridge messages into a profile workspace",
|
|
543
|
+
promptSnippet: "Import attachments from Proton Bridge into the active workspace",
|
|
544
|
+
promptGuidelines: [
|
|
545
|
+
"Use the active profile defaults unless the user explicitly overrides mailbox or period.",
|
|
546
|
+
"The tool stages raw mail and attachments under the profile workspace for later adaptation.",
|
|
547
|
+
],
|
|
548
|
+
parameters: Type.Object({
|
|
549
|
+
mailbox: Type.Optional(
|
|
550
|
+
Type.String({
|
|
551
|
+
description: "Mailbox name; defaults to the active profile mailbox if set",
|
|
552
|
+
}),
|
|
553
|
+
),
|
|
554
|
+
period: Type.Optional(Type.String({ description: "Optional month filter such as 2026-04" })),
|
|
555
|
+
query: Type.Optional(
|
|
556
|
+
Type.String({ description: "Optional subject/from/attachment substring filter" }),
|
|
557
|
+
),
|
|
558
|
+
unseenOnly: Type.Optional(Type.Boolean({ description: "If true, limit to unseen messages" })),
|
|
559
|
+
markSeen: Type.Optional(
|
|
560
|
+
Type.Boolean({ description: "If true, mark imported messages as seen" }),
|
|
561
|
+
),
|
|
562
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of messages to import" })),
|
|
563
|
+
workspaceRoot: Type.Optional(
|
|
564
|
+
Type.String({ description: "Optional relative workspace root for staged imports" }),
|
|
565
|
+
),
|
|
566
|
+
}),
|
|
567
|
+
async execute(
|
|
568
|
+
_id: string,
|
|
569
|
+
params: {
|
|
570
|
+
mailbox?: string;
|
|
571
|
+
period?: string;
|
|
572
|
+
query?: string;
|
|
573
|
+
unseenOnly?: boolean;
|
|
574
|
+
markSeen?: boolean;
|
|
575
|
+
limit?: number;
|
|
576
|
+
workspaceRoot?: string;
|
|
577
|
+
},
|
|
578
|
+
_signal: AbortSignal,
|
|
579
|
+
_onUpdate: unknown,
|
|
580
|
+
ctx: ToolContext,
|
|
581
|
+
) {
|
|
582
|
+
const profile = await resolveProtonMailActiveProfile(ctx.cwd);
|
|
583
|
+
const period = params.period ? parseMonthPeriod(params.period) : undefined;
|
|
584
|
+
if (params.period && !period)
|
|
585
|
+
throw new Error(`Invalid period \`${params.period}\`. Expected YYYY-MM.`);
|
|
586
|
+
const resolvedPeriod = period ?? profile.policy.default_period;
|
|
587
|
+
if (!resolvedPeriod)
|
|
588
|
+
throw new Error("No period provided and the active profile has no default_period.");
|
|
589
|
+
const query = params.query?.trim() || profile.policy.mailbox_filter;
|
|
590
|
+
const config = await getProtonBridgeConfig(profile.policy.default_mailbox);
|
|
591
|
+
if (!config.username || !config.password)
|
|
592
|
+
throw new Error(protonMailSetupHint(profile.profile));
|
|
593
|
+
const workspaceRoot = resolveProtonMailImportWorkspaceRoot(
|
|
594
|
+
profile.profile,
|
|
595
|
+
params.workspaceRoot || profile.policy.import_workspace_root,
|
|
596
|
+
);
|
|
597
|
+
const result = await runProtonBridgeImportAttachments(config, {
|
|
598
|
+
cwd: ctx.cwd,
|
|
599
|
+
workspaceRoot,
|
|
600
|
+
period: resolvedPeriod,
|
|
601
|
+
mailbox: params.mailbox,
|
|
602
|
+
profile: profile.profile,
|
|
603
|
+
query,
|
|
604
|
+
unseenOnly: params.unseenOnly ?? false,
|
|
605
|
+
markSeen: params.markSeen ?? false,
|
|
606
|
+
limit: params.limit ?? 100,
|
|
607
|
+
});
|
|
608
|
+
return {
|
|
609
|
+
content: [
|
|
610
|
+
{ type: "text", text: trimText(formatImportSummary(result, resolvedPeriod), 160, 16000) },
|
|
611
|
+
],
|
|
612
|
+
details: result,
|
|
613
|
+
};
|
|
614
|
+
},
|
|
615
|
+
renderCall(args: { mailbox?: string; period?: string }, theme: Theme) {
|
|
616
|
+
return new Text(
|
|
617
|
+
`${theme.fg("toolTitle", theme.bold("protonmail_import_attachments "))}${theme.fg("dim", `${args.mailbox ?? "default mailbox"}${args.period ? ` ${args.period}` : ""}`)}`,
|
|
618
|
+
0,
|
|
619
|
+
0,
|
|
620
|
+
);
|
|
621
|
+
},
|
|
622
|
+
renderResult: renderToolResult,
|
|
623
|
+
});
|
|
624
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
import { Data, Effect } from "effect";
|
|
5
|
+
|
|
6
|
+
const execFile = promisify(execFileCallback);
|
|
7
|
+
|
|
8
|
+
function trimMatchingQuotes(value: string): string {
|
|
9
|
+
if (
|
|
10
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
11
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
12
|
+
) {
|
|
13
|
+
return value.slice(1, -1);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractOnePasswordReference(rawValue: string): string | undefined {
|
|
19
|
+
const value = rawValue.trim();
|
|
20
|
+
if (!value) return undefined;
|
|
21
|
+
if (value.startsWith("op://")) return value;
|
|
22
|
+
|
|
23
|
+
const wrappedCommand = value.match(/^\$\(\s*op\s+read\s+(.+?)\s*\)$/i);
|
|
24
|
+
if (wrappedCommand) {
|
|
25
|
+
const target = trimMatchingQuotes(wrappedCommand[1].trim());
|
|
26
|
+
return target.startsWith("op://") ? target : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const plainCommand = value.match(/^op\s+read\s+(.+)$/i);
|
|
30
|
+
if (plainCommand) {
|
|
31
|
+
const target = trimMatchingQuotes(plainCommand[1].trim());
|
|
32
|
+
return target.startsWith("op://") ? target : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function looksLikeOnePasswordSecretReference(rawValue: string): boolean {
|
|
39
|
+
return Boolean(extractOnePasswordReference(rawValue));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class SecretReferenceError extends Data.TaggedError("SecretReferenceError")<{
|
|
43
|
+
message: string;
|
|
44
|
+
}> {}
|
|
45
|
+
|
|
46
|
+
function toSecretReferenceError(
|
|
47
|
+
label: string,
|
|
48
|
+
reference: string,
|
|
49
|
+
error: unknown,
|
|
50
|
+
): SecretReferenceError {
|
|
51
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
52
|
+
return new SecretReferenceError({
|
|
53
|
+
message: `Failed to resolve ${label} from 1Password reference ${reference}. Make sure the 1Password CLI is installed and authenticated. ${message}`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveSecretReferenceEffect(rawValue: string, label = "secret") {
|
|
58
|
+
const reference = extractOnePasswordReference(rawValue);
|
|
59
|
+
if (!reference) return Effect.succeed(rawValue);
|
|
60
|
+
|
|
61
|
+
return Effect.tryPromise({
|
|
62
|
+
try: () => execFile("op", ["read", reference]),
|
|
63
|
+
catch: (error) => toSecretReferenceError(label, reference, error),
|
|
64
|
+
}).pipe(
|
|
65
|
+
Effect.flatMap(({ stdout }) => {
|
|
66
|
+
const secret = stdout.trim();
|
|
67
|
+
if (!secret) {
|
|
68
|
+
return Effect.fail(
|
|
69
|
+
new SecretReferenceError({
|
|
70
|
+
message: `1Password reference ${reference} resolved to an empty value.`,
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return Effect.succeed(secret);
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function resolveSecretReference(rawValue: string, label = "secret"): Promise<string> {
|
|
80
|
+
return Effect.runPromise(resolveSecretReferenceEffect(rawValue, label));
|
|
81
|
+
}
|