pi-protonmail 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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.0 - 2026-07-02
6
+
7
+ - Added outgoing Proton Bridge tools for draft creation, SMTP sending, message moving, and label application.
8
+ - Added MIME composition with local attachments through `nodemailer`.
9
+ - Added profile `default_from` support for outgoing mail sender resolution.
10
+ - Added Conventional Commits guidance for future repository commits.
11
+
12
+ ## 0.1.0
13
+
5
14
  - Kept `/protonmail` as the single config command and moved Bridge/status/message handling into `protonmail_*` tools.
6
15
  - Replaced the Python Bridge helper with a native TypeScript module and kept attachment import staging under profile-specific workspaces.
7
16
  - Aligned the package metadata and docs with the Proton Mail workflow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-protonmail",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Proton Mail Bridge Pi extension for mailbox discovery and attachment imports.",
6
6
  "homepage": "https://github.com/eirenik0/pi-protonmail#readme",
@@ -50,10 +50,12 @@
50
50
  "dependencies": {
51
51
  "effect": "^3.0.0",
52
52
  "imapflow": "^1.1.1",
53
- "mailparser": "^3.9.0"
53
+ "mailparser": "^3.9.0",
54
+ "nodemailer": "^6.10.1"
54
55
  },
55
56
  "devDependencies": {
56
- "@biomejs/biome": "^2.5.0"
57
+ "@biomejs/biome": "^2.5.0",
58
+ "@types/nodemailer": "^8.0.1"
57
59
  },
58
60
  "peerDependencies": {
59
61
  "@earendil-works/pi-coding-agent": "*",
@@ -1,17 +1,24 @@
1
- import { access, copyFile, mkdir, writeFile } from "node:fs/promises";
1
+ import { access, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { createConnection } from "node:net";
3
- import { join, relative } from "node:path";
3
+ import { basename, isAbsolute, join, relative } from "node:path";
4
4
 
5
5
  import { ImapFlow } from "imapflow";
6
6
  import { simpleParser } from "mailparser";
7
+ import nodemailer from "nodemailer";
8
+ import MailComposer from "nodemailer/lib/mail-composer/index.js";
9
+ import type Mail from "nodemailer/lib/mailer/index.js";
7
10
 
8
11
  import type {
12
+ ApplyLabelsResult,
9
13
  BridgeStatusResult,
14
+ CreateDraftResult,
10
15
  MailboxInfo,
11
16
  MailboxListResult,
12
17
  MessageInfo,
13
18
  MessageListResult,
19
+ MoveMessageResult,
14
20
  ProtonBridgeConfig,
21
+ SendMessageResult,
15
22
  } from "./types.ts";
16
23
 
17
24
  export interface ProtonBridgeImportResult {
@@ -46,7 +53,7 @@ interface ImportOptions {
46
53
  cwd: string;
47
54
  workspaceRoot: string;
48
55
  period: string;
49
- mailbox: string;
56
+ mailbox?: string;
50
57
  profile: string;
51
58
  unseenOnly?: boolean;
52
59
  query?: string;
@@ -54,6 +61,37 @@ interface ImportOptions {
54
61
  limit?: number;
55
62
  }
56
63
 
64
+ interface OutgoingMessageOptions {
65
+ cwd: string;
66
+ from: string;
67
+ to: string[];
68
+ cc?: string[];
69
+ bcc?: string[];
70
+ subject: string;
71
+ body: string;
72
+ attachments?: string[];
73
+ }
74
+
75
+ interface CreateDraftOptions extends OutgoingMessageOptions {
76
+ draftsMailbox?: string;
77
+ }
78
+
79
+ interface SendMessageOptions extends OutgoingMessageOptions {
80
+ saveToMailbox?: string;
81
+ }
82
+
83
+ interface MoveMessageOptions {
84
+ mailbox: string;
85
+ uid: string;
86
+ destination: string;
87
+ }
88
+
89
+ interface ApplyLabelsOptions {
90
+ mailbox: string;
91
+ uid: string;
92
+ labels: string[];
93
+ }
94
+
57
95
  function sanitizeFilename(value: string): string {
58
96
  const text = value.replace(/[\\/:*?"<>|\r\n]+/g, "_").trim();
59
97
  return text.replace(/\s+/g, " ") || "unnamed";
@@ -244,6 +282,80 @@ async function ensureDir(path: string): Promise<void> {
244
282
  await mkdir(path, { recursive: true });
245
283
  }
246
284
 
285
+ function requireBridgeCredentials(
286
+ config: ProtonBridgeConfig,
287
+ ): asserts config is ProtonBridgeConfig & {
288
+ username: string;
289
+ password: string;
290
+ } {
291
+ if (!config.username || !config.password)
292
+ throw new Error("Missing Proton Bridge username/password.");
293
+ }
294
+
295
+ function resolveLocalPath(cwd: string, path: string): string {
296
+ return isAbsolute(path) ? path : join(cwd, path);
297
+ }
298
+
299
+ async function buildAttachmentOptions(
300
+ cwd: string,
301
+ attachments?: string[],
302
+ ): Promise<Mail.Attachment[]> {
303
+ const files: Mail.Attachment[] = [];
304
+ for (const attachmentPath of attachments ?? []) {
305
+ const path = resolveLocalPath(cwd, attachmentPath);
306
+ files.push({
307
+ filename: basename(path),
308
+ content: await readFile(path),
309
+ });
310
+ }
311
+ return files;
312
+ }
313
+
314
+ async function buildMimeMessage(options: OutgoingMessageOptions, keepBcc = false): Promise<Buffer> {
315
+ const message = new MailComposer({
316
+ from: options.from,
317
+ to: options.to,
318
+ cc: options.cc,
319
+ bcc: options.bcc,
320
+ subject: options.subject,
321
+ text: options.body,
322
+ attachments: await buildAttachmentOptions(options.cwd, options.attachments),
323
+ });
324
+ const node = message.compile();
325
+ if (keepBcc) (node as unknown as { keepBcc: boolean }).keepBcc = true;
326
+ return node.build();
327
+ }
328
+
329
+ function appendUid(result: unknown): string | undefined {
330
+ if (!result || typeof result !== "object" || !("uid" in result)) return undefined;
331
+ const uid = (result as { uid?: unknown }).uid;
332
+ return uid == null ? undefined : String(uid);
333
+ }
334
+
335
+ function allRecipients(options: OutgoingMessageOptions): string[] {
336
+ return [...options.to, ...(options.cc ?? []), ...(options.bcc ?? [])].filter(Boolean);
337
+ }
338
+
339
+ function messageIdFromRaw(raw: Buffer): string | undefined {
340
+ const match = raw.toString("utf8").match(/^Message-ID:\s*(.+)$/im);
341
+ return match?.[1]?.trim();
342
+ }
343
+
344
+ function resolveLabelMailbox(label: string, mailboxes: MailboxInfo[]): string {
345
+ const trimmed = label.trim();
346
+ const candidates = [trimmed, `Labels/${trimmed}`];
347
+ for (const candidate of candidates) {
348
+ const exact = mailboxes.find((mailbox) => mailbox.name === candidate);
349
+ if (exact) return exact.name;
350
+ }
351
+ for (const candidate of candidates) {
352
+ const lower = candidate.toLowerCase();
353
+ const match = mailboxes.find((mailbox) => mailbox.name.toLowerCase() === lower);
354
+ if (match) return match.name;
355
+ }
356
+ return trimmed;
357
+ }
358
+
247
359
  export async function protonBridgeStatus(config: ProtonBridgeConfig): Promise<BridgeStatusResult> {
248
360
  const imapProbe = await probePort(config.host, config.imapPort);
249
361
  const smtpProbe = await probePort(config.host, config.smtpPort);
@@ -335,6 +447,143 @@ export async function protonBridgeListMessages(
335
447
  }
336
448
  }
337
449
 
450
+ export async function protonBridgeCreateDraft(
451
+ config: ProtonBridgeConfig,
452
+ options: CreateDraftOptions,
453
+ ): Promise<CreateDraftResult> {
454
+ requireBridgeCredentials(config);
455
+ const mailbox = options.draftsMailbox?.trim() || "Drafts";
456
+ const raw = await buildMimeMessage(options, true);
457
+ let client: ImapFlow | undefined;
458
+ try {
459
+ client = await connectImap(config);
460
+ const result = await client.append(mailbox, raw, ["\\Draft", "\\Seen"], new Date());
461
+ return {
462
+ mailbox,
463
+ uid: appendUid(result),
464
+ from: options.from,
465
+ to: options.to,
466
+ cc: options.cc,
467
+ bcc: options.bcc,
468
+ subject: options.subject,
469
+ attachment_count: options.attachments?.length ?? 0,
470
+ };
471
+ } finally {
472
+ await client?.logout().catch(() => undefined);
473
+ }
474
+ }
475
+
476
+ export async function protonBridgeSendMessage(
477
+ config: ProtonBridgeConfig,
478
+ options: SendMessageOptions,
479
+ ): Promise<SendMessageResult> {
480
+ requireBridgeCredentials(config);
481
+ const raw = await buildMimeMessage(options);
482
+ const security = normalizeSecurity(config.security);
483
+ const transport = nodemailer.createTransport({
484
+ host: config.host,
485
+ port: config.smtpPort,
486
+ secure: security === "ssl",
487
+ ignoreTLS: security === "plain",
488
+ requireTLS: security === "starttls",
489
+ auth: {
490
+ user: config.username,
491
+ pass: config.password,
492
+ },
493
+ tls: {
494
+ rejectUnauthorized: false,
495
+ },
496
+ });
497
+
498
+ let messageId = messageIdFromRaw(raw);
499
+ try {
500
+ const info = await transport.sendMail({
501
+ envelope: {
502
+ from: options.from,
503
+ to: allRecipients(options),
504
+ },
505
+ raw,
506
+ });
507
+ if (typeof info.messageId === "string") messageId = info.messageId;
508
+ } finally {
509
+ transport.close();
510
+ }
511
+
512
+ const result: SendMessageResult = {
513
+ from: options.from,
514
+ to: options.to,
515
+ cc: options.cc,
516
+ bcc: options.bcc,
517
+ subject: options.subject,
518
+ attachment_count: options.attachments?.length ?? 0,
519
+ message_id: messageId,
520
+ };
521
+
522
+ const saveToMailbox = options.saveToMailbox?.trim();
523
+ if (saveToMailbox) {
524
+ let client: ImapFlow | undefined;
525
+ try {
526
+ client = await connectImap(config);
527
+ const appendResult = await client.append(saveToMailbox, raw, ["\\Seen"], new Date());
528
+ result.saved_to_mailbox = saveToMailbox;
529
+ result.saved_uid = appendUid(appendResult);
530
+ } finally {
531
+ await client?.logout().catch(() => undefined);
532
+ }
533
+ }
534
+
535
+ return result;
536
+ }
537
+
538
+ export async function protonBridgeMoveMessage(
539
+ config: ProtonBridgeConfig,
540
+ options: MoveMessageOptions,
541
+ ): Promise<MoveMessageResult> {
542
+ requireBridgeCredentials(config);
543
+ let client: ImapFlow | undefined;
544
+ try {
545
+ client = await connectImap(config);
546
+ await client.mailboxOpen(options.mailbox, { readOnly: false });
547
+ await client.messageMove(options.uid, options.destination, { uid: true });
548
+ return {
549
+ uid: options.uid,
550
+ source: options.mailbox,
551
+ destination: options.destination,
552
+ };
553
+ } finally {
554
+ await client?.logout().catch(() => undefined);
555
+ }
556
+ }
557
+
558
+ export async function protonBridgeApplyLabels(
559
+ config: ProtonBridgeConfig,
560
+ options: ApplyLabelsOptions,
561
+ ): Promise<ApplyLabelsResult> {
562
+ requireBridgeCredentials(config);
563
+ const labels = [...new Set(options.labels.map((label) => label.trim()).filter(Boolean))];
564
+ if (labels.length === 0) throw new Error("At least one label is required.");
565
+ let client: ImapFlow | undefined;
566
+ try {
567
+ client = await connectImap(config);
568
+ const mailboxes = await listMailboxes(client);
569
+ await client.mailboxOpen(options.mailbox, { readOnly: false });
570
+ const labelMailboxes = [
571
+ ...new Set(labels.map((label) => resolveLabelMailbox(label, mailboxes))),
572
+ ];
573
+ for (const labelMailbox of labelMailboxes) {
574
+ await client.messageCopy(options.uid, labelMailbox, { uid: true });
575
+ }
576
+ return {
577
+ uid: options.uid,
578
+ mailbox: options.mailbox,
579
+ labels,
580
+ label_mailboxes: labelMailboxes,
581
+ };
582
+ } finally {
583
+ await client?.logout().catch(() => undefined);
584
+ }
585
+ }
586
+
338
587
  export async function protonBridgeImportAttachments(
339
588
  config: ProtonBridgeConfig,
340
589
  options: ImportOptions,
package/src/protonmail.ts CHANGED
@@ -10,19 +10,27 @@ import { Type } from "typebox";
10
10
  import { PREVIEW_LINES } from "./constants.ts";
11
11
  import { openProtonMailHub } from "./hub.ts";
12
12
  import {
13
+ protonBridgeApplyLabels as runProtonBridgeApplyLabels,
14
+ protonBridgeCreateDraft as runProtonBridgeCreateDraft,
13
15
  protonBridgeImportAttachments as runProtonBridgeImportAttachments,
14
16
  protonBridgeListMailboxes as runProtonBridgeListMailboxes,
15
17
  protonBridgeListMessages as runProtonBridgeListMessages,
18
+ protonBridgeMoveMessage as runProtonBridgeMoveMessage,
19
+ protonBridgeSendMessage as runProtonBridgeSendMessage,
16
20
  protonBridgeStatus as runProtonBridgeStatus,
17
21
  } from "./proton-bridge.ts";
18
22
  import { resolveSecretReference } from "./secret-refs.ts";
19
23
  import type {
24
+ ApplyLabelsResult,
20
25
  BridgeStatusResult,
21
26
  CommandContext,
27
+ CreateDraftResult,
22
28
  MailboxListResult,
23
29
  MessageListResult,
30
+ MoveMessageResult,
24
31
  ProtonBridgeConfig,
25
32
  ProtonMailWorkingProfile,
33
+ SendMessageResult,
26
34
  ToolContext,
27
35
  } from "./types.ts";
28
36
  import {
@@ -216,6 +224,13 @@ async function listProtonMessages(
216
224
  return runProtonBridgeListMessages(config, mailbox, period, query, unseenOnly, limit);
217
225
  }
218
226
 
227
+ function resolveOutgoingFrom(from: string | undefined, profile: ProtonMailWorkingProfile): string {
228
+ const resolved = from?.trim() || profile.policy.default_from?.trim();
229
+ if (!resolved)
230
+ throw new Error("No from address provided and the active profile has no default_from.");
231
+ return resolved;
232
+ }
233
+
219
234
  function formatStatusSummary(result: BridgeStatusResult): string {
220
235
  const lines = [
221
236
  "# Proton Bridge Status",
@@ -296,6 +311,62 @@ function formatMessageSummary(result: MessageListResult, period?: string): strin
296
311
  return lines.join("\n");
297
312
  }
298
313
 
314
+ function formatCreateDraftSummary(result: CreateDraftResult): string {
315
+ const lines = [
316
+ "# Proton Mail draft created",
317
+ "",
318
+ `- Mailbox: \`${result.mailbox}\``,
319
+ `- UID: ${result.uid ? `\`${result.uid}\`` : "—"}`,
320
+ `- From: ${result.from}`,
321
+ `- To: ${result.to.join(", ")}`,
322
+ `- Subject: ${result.subject}`,
323
+ `- Attachments: ${result.attachment_count}`,
324
+ ];
325
+ if (result.cc?.length) lines.push(`- Cc: ${result.cc.join(", ")}`);
326
+ if (result.bcc?.length) lines.push(`- Bcc: ${result.bcc.join(", ")}`);
327
+ return lines.join("\n");
328
+ }
329
+
330
+ function formatSendSummary(result: SendMessageResult): string {
331
+ const lines = [
332
+ "# Proton Mail message sent",
333
+ "",
334
+ `- Message ID: ${result.message_id ? `\`${result.message_id}\`` : "—"}`,
335
+ `- From: ${result.from}`,
336
+ `- To: ${result.to.join(", ")}`,
337
+ `- Subject: ${result.subject}`,
338
+ `- Attachments: ${result.attachment_count}`,
339
+ ];
340
+ if (result.cc?.length) lines.push(`- Cc: ${result.cc.join(", ")}`);
341
+ if (result.bcc?.length) lines.push(`- Bcc: ${result.bcc.join(", ")}`);
342
+ if (result.saved_to_mailbox)
343
+ lines.push(
344
+ `- Saved copy: \`${result.saved_to_mailbox}\`${result.saved_uid ? ` UID ${result.saved_uid}` : ""}`,
345
+ );
346
+ return lines.join("\n");
347
+ }
348
+
349
+ function formatMoveSummary(result: MoveMessageResult): string {
350
+ return [
351
+ "# Proton Mail message moved",
352
+ "",
353
+ `- UID: \`${result.uid}\``,
354
+ `- Source: \`${result.source}\``,
355
+ `- Destination: \`${result.destination}\``,
356
+ ].join("\n");
357
+ }
358
+
359
+ function formatApplyLabelsSummary(result: ApplyLabelsResult): string {
360
+ return [
361
+ "# Proton Mail labels applied",
362
+ "",
363
+ `- UID: \`${result.uid}\``,
364
+ `- Mailbox: \`${result.mailbox}\``,
365
+ `- Labels: ${result.labels.map((label) => `\`${label}\``).join(", ")}`,
366
+ `- Label mailboxes: ${result.label_mailboxes.map((label) => `\`${label}\``).join(", ")}`,
367
+ ].join("\n");
368
+ }
369
+
299
370
  function formatImportSummary(
300
371
  result: {
301
372
  workspace_root: string;
@@ -536,6 +607,234 @@ export default function registerProtonBridgeExtension(pi: ExtensionAPI) {
536
607
  renderResult: renderToolResult,
537
608
  });
538
609
 
610
+ pi.registerTool({
611
+ name: "protonmail_create_draft",
612
+ label: "ProtonMail Create Draft",
613
+ description: "Create a Proton Mail draft through Bridge IMAP APPEND",
614
+ promptSnippet: "Create a draft with optional attachments in the Drafts mailbox",
615
+ promptGuidelines: [
616
+ "Use from when the sender must be explicit; otherwise the active profile default_from is used.",
617
+ "Attachments are local file paths and are embedded into a multipart MIME message before IMAP APPEND.",
618
+ ],
619
+ parameters: Type.Object({
620
+ from: Type.Optional(
621
+ Type.String({ description: "Sender address; defaults to profile default_from" }),
622
+ ),
623
+ to: Type.Array(Type.String(), { description: "Recipient email addresses" }),
624
+ cc: Type.Optional(Type.Array(Type.String(), { description: "Cc recipient email addresses" })),
625
+ bcc: Type.Optional(
626
+ Type.Array(Type.String(), { description: "Bcc recipient email addresses" }),
627
+ ),
628
+ subject: Type.String({ description: "Email subject" }),
629
+ body: Type.String({ description: "Plain-text email body" }),
630
+ attachments: Type.Optional(
631
+ Type.Array(Type.String(), { description: "Local file paths to attach" }),
632
+ ),
633
+ draftsMailbox: Type.Optional(
634
+ Type.String({ description: "Drafts mailbox name; defaults to Drafts" }),
635
+ ),
636
+ }),
637
+ async execute(
638
+ _id: string,
639
+ params: {
640
+ from?: string;
641
+ to: string[];
642
+ cc?: string[];
643
+ bcc?: string[];
644
+ subject: string;
645
+ body: string;
646
+ attachments?: string[];
647
+ draftsMailbox?: string;
648
+ },
649
+ _signal: AbortSignal,
650
+ _onUpdate: unknown,
651
+ ctx: ToolContext,
652
+ ) {
653
+ const profile = await resolveProtonMailActiveProfile(ctx.cwd);
654
+ const config = await getProtonBridgeConfig(profile.policy.default_mailbox);
655
+ if (!config.username || !config.password)
656
+ throw new Error(protonMailSetupHint(profile.profile));
657
+ const result = await runProtonBridgeCreateDraft(config, {
658
+ cwd: ctx.cwd,
659
+ from: resolveOutgoingFrom(params.from, profile),
660
+ to: params.to,
661
+ cc: params.cc,
662
+ bcc: params.bcc,
663
+ subject: params.subject,
664
+ body: params.body,
665
+ attachments: params.attachments,
666
+ draftsMailbox: params.draftsMailbox,
667
+ });
668
+ return {
669
+ content: [{ type: "text", text: trimText(formatCreateDraftSummary(result), 160, 16000) }],
670
+ details: result,
671
+ };
672
+ },
673
+ renderCall(args: { subject: string; to: string[] }, theme: Theme) {
674
+ return new Text(
675
+ `${theme.fg("toolTitle", theme.bold("protonmail_create_draft "))}${theme.fg("dim", `${args.subject} → ${args.to.join(", ")}`)}`,
676
+ 0,
677
+ 0,
678
+ );
679
+ },
680
+ renderResult: renderToolResult,
681
+ });
682
+
683
+ pi.registerTool({
684
+ name: "protonmail_send",
685
+ label: "ProtonMail Send",
686
+ description: "Send a Proton Mail message through Bridge SMTP",
687
+ promptSnippet: "Send a message with optional attachments through Proton Bridge SMTP",
688
+ promptGuidelines: [
689
+ "Use from when the sender must be explicit; otherwise the active profile default_from is used.",
690
+ "Use saveToMailbox only when a sent or issued copy should also be appended to an IMAP folder.",
691
+ ],
692
+ parameters: Type.Object({
693
+ from: Type.Optional(
694
+ Type.String({ description: "Sender address; defaults to profile default_from" }),
695
+ ),
696
+ to: Type.Array(Type.String(), { description: "Recipient email addresses" }),
697
+ cc: Type.Optional(Type.Array(Type.String(), { description: "Cc recipient email addresses" })),
698
+ bcc: Type.Optional(
699
+ Type.Array(Type.String(), { description: "Bcc recipient email addresses" }),
700
+ ),
701
+ subject: Type.String({ description: "Email subject" }),
702
+ body: Type.String({ description: "Plain-text email body" }),
703
+ attachments: Type.Optional(
704
+ Type.Array(Type.String(), { description: "Local file paths to attach" }),
705
+ ),
706
+ saveToMailbox: Type.Optional(
707
+ Type.String({ description: "Optional mailbox for appending a sent copy" }),
708
+ ),
709
+ }),
710
+ async execute(
711
+ _id: string,
712
+ params: {
713
+ from?: string;
714
+ to: string[];
715
+ cc?: string[];
716
+ bcc?: string[];
717
+ subject: string;
718
+ body: string;
719
+ attachments?: string[];
720
+ saveToMailbox?: string;
721
+ },
722
+ _signal: AbortSignal,
723
+ _onUpdate: unknown,
724
+ ctx: ToolContext,
725
+ ) {
726
+ const profile = await resolveProtonMailActiveProfile(ctx.cwd);
727
+ const config = await getProtonBridgeConfig(profile.policy.default_mailbox);
728
+ if (!config.username || !config.password)
729
+ throw new Error(protonMailSetupHint(profile.profile));
730
+ const result = await runProtonBridgeSendMessage(config, {
731
+ cwd: ctx.cwd,
732
+ from: resolveOutgoingFrom(params.from, profile),
733
+ to: params.to,
734
+ cc: params.cc,
735
+ bcc: params.bcc,
736
+ subject: params.subject,
737
+ body: params.body,
738
+ attachments: params.attachments,
739
+ saveToMailbox: params.saveToMailbox,
740
+ });
741
+ return {
742
+ content: [{ type: "text", text: trimText(formatSendSummary(result), 160, 16000) }],
743
+ details: result,
744
+ };
745
+ },
746
+ renderCall(args: { subject: string; to: string[] }, theme: Theme) {
747
+ return new Text(
748
+ `${theme.fg("toolTitle", theme.bold("protonmail_send "))}${theme.fg("dim", `${args.subject} → ${args.to.join(", ")}`)}`,
749
+ 0,
750
+ 0,
751
+ );
752
+ },
753
+ renderResult: renderToolResult,
754
+ });
755
+
756
+ pi.registerTool({
757
+ name: "protonmail_move_message",
758
+ label: "ProtonMail Move Message",
759
+ description: "Move a Proton Bridge message between IMAP mailboxes",
760
+ promptSnippet: "Move a message UID from one Proton folder to another",
761
+ promptGuidelines: [
762
+ "Use protonmail_list_messages first when you need to identify the source mailbox UID.",
763
+ "Pass the exact mailbox names returned by Proton Bridge.",
764
+ ],
765
+ parameters: Type.Object({
766
+ mailbox: Type.String({ description: "Source mailbox name" }),
767
+ uid: Type.String({ description: "Message UID in the source mailbox" }),
768
+ destination: Type.String({ description: "Destination mailbox name" }),
769
+ }),
770
+ async execute(
771
+ _id: string,
772
+ params: { mailbox: string; uid: string; destination: string },
773
+ _signal: AbortSignal,
774
+ _onUpdate: unknown,
775
+ ctx: ToolContext,
776
+ ) {
777
+ const profile = await resolveProtonMailActiveProfile(ctx.cwd);
778
+ const config = await getProtonBridgeConfig(profile.policy.default_mailbox);
779
+ if (!config.username || !config.password)
780
+ throw new Error(protonMailSetupHint(profile.profile));
781
+ const result = await runProtonBridgeMoveMessage(config, params);
782
+ return {
783
+ content: [{ type: "text", text: trimText(formatMoveSummary(result), 160, 16000) }],
784
+ details: result,
785
+ };
786
+ },
787
+ renderCall(args: { mailbox: string; uid: string; destination: string }, theme: Theme) {
788
+ return new Text(
789
+ `${theme.fg("toolTitle", theme.bold("protonmail_move_message "))}${theme.fg("dim", `${args.mailbox} UID ${args.uid} → ${args.destination}`)}`,
790
+ 0,
791
+ 0,
792
+ );
793
+ },
794
+ renderResult: renderToolResult,
795
+ });
796
+
797
+ pi.registerTool({
798
+ name: "protonmail_apply_labels",
799
+ label: "ProtonMail Apply Labels",
800
+ description: "Apply Proton labels to a message through Bridge IMAP copy",
801
+ promptSnippet: "Apply one or more Proton labels to a message UID",
802
+ promptGuidelines: [
803
+ "Use protonmail_list_mailboxes when you need the exact label mailbox names returned by Proton Bridge.",
804
+ "Bare label names are resolved to matching mailboxes or Labels/<name> when available.",
805
+ ],
806
+ parameters: Type.Object({
807
+ mailbox: Type.String({ description: "Source mailbox name" }),
808
+ uid: Type.String({ description: "Message UID in the source mailbox" }),
809
+ labels: Type.Array(Type.String(), { description: "Labels or label mailbox paths to apply" }),
810
+ }),
811
+ async execute(
812
+ _id: string,
813
+ params: { mailbox: string; uid: string; labels: string[] },
814
+ _signal: AbortSignal,
815
+ _onUpdate: unknown,
816
+ ctx: ToolContext,
817
+ ) {
818
+ const profile = await resolveProtonMailActiveProfile(ctx.cwd);
819
+ const config = await getProtonBridgeConfig(profile.policy.default_mailbox);
820
+ if (!config.username || !config.password)
821
+ throw new Error(protonMailSetupHint(profile.profile));
822
+ const result = await runProtonBridgeApplyLabels(config, params);
823
+ return {
824
+ content: [{ type: "text", text: trimText(formatApplyLabelsSummary(result), 160, 16000) }],
825
+ details: result,
826
+ };
827
+ },
828
+ renderCall(args: { mailbox: string; uid: string; labels: string[] }, theme: Theme) {
829
+ return new Text(
830
+ `${theme.fg("toolTitle", theme.bold("protonmail_apply_labels "))}${theme.fg("dim", `${args.mailbox} UID ${args.uid} + ${args.labels.join(", ")}`)}`,
831
+ 0,
832
+ 0,
833
+ );
834
+ },
835
+ renderResult: renderToolResult,
836
+ });
837
+
539
838
  pi.registerTool({
540
839
  name: "protonmail_import_attachments",
541
840
  label: "ProtonMail Import Attachments",
package/src/types.ts CHANGED
@@ -15,6 +15,7 @@ export interface ProtonMailProfilePolicy {
15
15
  mailbox_filter?: string;
16
16
  default_period?: string;
17
17
  import_workspace_root?: string;
18
+ default_from?: string;
18
19
  }
19
20
 
20
21
  export interface ProtonMailWorkspaceConfig {
@@ -82,6 +83,42 @@ export interface MessageListResult {
82
83
  messages: MessageInfo[];
83
84
  }
84
85
 
86
+ export interface CreateDraftResult {
87
+ mailbox: string;
88
+ uid?: string;
89
+ from: string;
90
+ to: string[];
91
+ cc?: string[];
92
+ bcc?: string[];
93
+ subject: string;
94
+ attachment_count: number;
95
+ }
96
+
97
+ export interface SendMessageResult {
98
+ from: string;
99
+ to: string[];
100
+ cc?: string[];
101
+ bcc?: string[];
102
+ subject: string;
103
+ attachment_count: number;
104
+ message_id?: string;
105
+ saved_to_mailbox?: string;
106
+ saved_uid?: string;
107
+ }
108
+
109
+ export interface MoveMessageResult {
110
+ uid: string;
111
+ source: string;
112
+ destination: string;
113
+ }
114
+
115
+ export interface ApplyLabelsResult {
116
+ uid: string;
117
+ mailbox: string;
118
+ labels: string[];
119
+ label_mailboxes: string[];
120
+ }
121
+
85
122
  export type CommandContext = Pick<ExtensionCommandContext, "cwd" | "hasUI" | "ui">;
86
123
 
87
124
  export interface ToolContext {