proton-mail-bridge-client 2.1.7 → 2.1.8

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/README.md CHANGED
@@ -311,6 +311,7 @@ First, enable **Split Addresses** in Proton Bridge and configure each address as
311
311
  - Search & read: `search_indexed_emails`, `get_emails`, `get_threads`, `get_thread_by_id`, `count_messages`, `get_labels`, `folder_stats`, `top_senders`, `get_contacts`, `get_volume_trends`, `get_email_analytics`, `get_email_stats`, `get_folders`
312
312
  - Triage: `get_inbox_digest`, `get_follow_up_candidates`, `get_actionable_threads`, `find_document_threads`, `prepare_meeting_context`
313
313
  - Diagnostics: `get_connection_status`, `get_runtime_status`, `run_doctor` (include an `accounts` array showing per-account status)
314
+ - Drafts: `list_drafts` — lists every configured account's drafts, merged and re-sorted by last update; supports optional `limit`/`offset`
314
315
 
315
316
  **Account-specific (prefix to target):**
316
317
  - Actions: `mark_email_read`, `star_email`, `move_email`, `delete_email`, etc. — prefix the `emailId` with account slug if targeting a non-primary account
@@ -318,7 +319,7 @@ First, enable **Split Addresses** in Proton Bridge and configure each address as
318
319
  - Send: `send_email`, `reply_to_email`, `forward_email` — match the `from` address to route through the correct account's SMTP
319
320
 
320
321
  **Primary-only (for now):**
321
- - `list_drafts`, `list_remote_drafts` — show primary account drafts only
322
+ - `list_remote_drafts` — shows the primary account's remote Proton Drafts folder only
322
323
  - Thread actions (`move_thread`, `delete_thread`, `flag_thread`) — operate on the account the thread ID's prefix names
323
324
 
324
325
  ### Verify setup
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ import type { BulkOperationResult, DraftRecord, EmailDetail, ProtonMailConfig, S
12
12
  import { AccountManager, type AccountBundle } from "./services/account-manager.js";
13
13
  export declare const CORE_TOOL_NAMES: Set<string>;
14
14
  export declare function withAudit<T>(auditService: AuditService, tool: string, input: Record<string, unknown>, operation: () => Promise<T>): Promise<T>;
15
+ export declare function optionalClearableString(args: Record<string, unknown>, key: string): string | undefined;
15
16
  export declare function headerString(headers: Record<string, unknown> | undefined, name: string): string | undefined;
16
17
  export declare function parseAuthenticationResults(value?: string): {
17
18
  dkim?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AASA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAYnE,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,sCAAsC,CAAC;AAC5E,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,EAAE,iBAAiB,EAAE,MAAM,mCAAmC,CAAC;AACtE,OAAO,EAA2H,iBAAiB,EAA+B,MAAM,mCAAmC,CAAC;AAC5N,OAAO,EAAmC,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAC1F,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAE7D,OAAO,KAAK,EAMV,mBAAmB,EAEnB,WAAW,EAIX,WAAW,EAIX,gBAAgB,EAChB,cAAc,EAGf,MAAM,kBAAkB,CAAC;AAmB1B,OAAO,EAAE,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAsrDnF,eAAO,MAAM,eAAe,aAyB1B,CAAC;AAgGH,wBAAsB,SAAS,CAAC,CAAC,EAC/B,YAAY,EAAE,YAAY,EAC1B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAC1B,OAAO,CAAC,CAAC,CAAC,CAwBZ;AAmED,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAK3G;AAKD,wBAAgB,0BAA0B,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAI1G;AASD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAa9E;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,WAAW,GAAG;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,CAS7F;AAuLD,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAW5E;AAED,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAe/E;AAWD,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,OAAO,GAChB;IAAE,EAAE,EAAE,MAAM,EAAE,CAAC;IAAC,EAAE,EAAE,MAAM,EAAE,CAAA;CAAE,CAyBhC;AA6ED,wBAAgB,aAAa,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CA4CtE;AAcD,wBAAgB,gCAAgC,CAAC,KAAK,EAAE,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAYlE;AAoBD,wBAAgB,4BAA4B,CAAC,CAAC,SAAS;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,KAAK,EAAE,CAAC,KAUhF;AAQD,wBAAgB,4BAA4B,CAAC,CAAC,SAAS;IAAE,OAAO,EAAE,cAAc,CAAA;CAAE,EAAE,MAAM,EAAE,CAAC,KAmB5F;AA8WD,wBAAgB,4BAA4B,CAAC,CAAC,EAAE,YAAY,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,CAAC,EAAE,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAKjI;AA8PD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAEzE;AAED,wBAAgB,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAOzE;AAED,UAAU,mBAAmB;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf;AASD,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,MAAM,EAAE,GAAG,SAAS,EAC9B,MAAM,EAAE,MAAM,EACd,kBAAkB,CAAC,EAAE,MAAM,GAC1B,mBAAmB,EAAE,CA8CvB;AAED,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,mBAAmB,EAC3B,gBAAgB,EAAE,mBAAmB,EAAE,GACtC,mBAAmB,CAmBrB;AAWD,wBAAgB,sBAAsB,CACpC,cAAc,EAAE,cAAc,EAC9B,QAAQ,EAAE,MAAM,EAAE,GACjB,GAAG,CAAC,MAAM,GAAG,SAAS,EAAE;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAYvE;AAOD,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,mBAAmB,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,mBAAmB,CAQ3G;AAED,wBAAgB,cAAc,CAAC,QAAQ,EAAE,mBAAmB,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,mBAAmB,EAAE,CAK/G;AAID,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,mBAAmB,EAAE,GAAG,mBAAmB,CASpF;AAsQD,wBAAgB,kBAAkB,IAAI,gBAAgB,CAkGrD;AAED,wBAAgB,YAAY,CAC1B,MAAM,EAAE,gBAAgB,EACxB,OAAO,GAAE;IACP,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC1B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAwvIP;AAED,wBAAsB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAmE1C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1841,6 +1841,23 @@ function optionalString(args, key) {
1841
1841
  const value = args[key];
1842
1842
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
1843
1843
  }
1844
+ // Found live (external review): update_draft's body:'' and replyTo:'' both
1845
+ // returned success but left the old values in place — optionalString collapses
1846
+ // an explicitly-empty string to undefined the same as an omitted field
1847
+ // entirely, and DraftStoreService.updateDraft's patch treats undefined as
1848
+ // "don't touch this field" (`patch.body ?? existing.body`). So there was no
1849
+ // way to actually clear either field once set — only used for update_draft's
1850
+ // body/replyTo, which are the two fields the review confirmed this breaks
1851
+ // (cc/bcc/attachments already have their own clear path via
1852
+ // `args.x === undefined ? undefined : ...`, checked separately). Not applied
1853
+ // to optionalString itself, since most of its many other callers legitimately
1854
+ // want "" treated the same as "not provided".
1855
+ export function optionalClearableString(args, key) {
1856
+ if (!(key in args) || args[key] === undefined) {
1857
+ return undefined;
1858
+ }
1859
+ return typeof args[key] === "string" ? args[key].trim() : undefined;
1860
+ }
1844
1861
  // Shared by get_email_by_id and get_emails_by_ids: applies body preference/
1845
1862
  // truncation and strips the full raw header map by default (needless token
1846
1863
  // bloat — get_email_by_id re-adds it when showHeaders is explicitly true).
@@ -2489,7 +2506,7 @@ async function syncDraftToRemote(draftStore, smtpService, imapService, draft) {
2489
2506
  // signature into the draft body themselves, got it appended a second time
2490
2507
  // on every remote sync.
2491
2508
  appendSignature: false,
2492
- });
2509
+ }, /* preserveBcc */ true);
2493
2510
  const remoteDraft = await imapService.upsertRemoteDraft({
2494
2511
  raw,
2495
2512
  messageId: draft.draftMessageId,
@@ -4246,10 +4263,24 @@ export function createServer(config, options = {}) {
4246
4263
  ]);
4247
4264
  }
4248
4265
  case "list_drafts": {
4249
- // Scope limitation (follow-up round): stays scoped to the primary account's
4250
- // drafts only — a non-primary account's drafts aren't listed here yet. Use
4251
- // get_draft with that account's prefixed draftId if you already have one.
4252
- const drafts = await draftStore.listDrafts(normalizeBoolean(args.includeSent, false));
4266
+ // Found live (external review): this stayed scoped to the primary
4267
+ // account's own drafts only — a draft created via create_draft(from:
4268
+ // secondary@example.com) is real and gettable by its prefixed draftId,
4269
+ // but never showed up here, so day-to-day draft discovery silently
4270
+ // missed every non-primary account's drafts. Now fans out across every
4271
+ // configured account, same pattern as list_scheduled_sends/
4272
+ // list_snoozed, and re-sorts the merged set by updatedAt (each
4273
+ // account's own list already arrives that way). Single-account guard
4274
+ // keeps byte-identical output for the common case.
4275
+ const includeSent = normalizeBoolean(args.includeSent, false);
4276
+ const allDrafts = accountManager.all().length === 1
4277
+ ? (await draftStore.listDrafts(includeSent)).map((draft) => ({ bundle: primaryBundle, draft }))
4278
+ : (await Promise.all(accountManager.all().map(async (bundle) => {
4279
+ const drafts = await bundle.draftStore.listDrafts(includeSent);
4280
+ return drafts.map((draft) => ({ bundle, draft: presentDraft(bundle, draft) }));
4281
+ })))
4282
+ .flat()
4283
+ .sort((left, right) => right.draft.updatedAt.localeCompare(left.draft.updatedAt));
4253
4284
  // Found live (external review): every draft's full body was returned
4254
4285
  // alongside the already-redacted attachments — confirmed live at ~261k
4255
4286
  // tokens for 107 drafts. Bodies are now truncated the same way a
@@ -4258,15 +4289,15 @@ export function createServer(config, options = {}) {
4258
4289
  // draft (just with truncated bodies/attachments), so this doesn't change
4259
4290
  // behavior for a caller not using the new params.
4260
4291
  const offset = normalizeLimit(args.offset, 0, 0, 10_000);
4261
- const limit = typeof args.limit === "number" ? normalizeLimit(args.limit, drafts.length, 1, 10_000) : undefined;
4262
- const page = limit !== undefined ? drafts.slice(offset, offset + limit) : drafts.slice(offset);
4292
+ const limit = typeof args.limit === "number" ? normalizeLimit(args.limit, allDrafts.length, 1, 10_000) : undefined;
4293
+ const page = limit !== undefined ? allDrafts.slice(offset, offset + limit) : allDrafts.slice(offset);
4263
4294
  return createTextResult({
4264
- total: drafts.length,
4295
+ total: allDrafts.length,
4265
4296
  offset,
4266
4297
  returned: page.length,
4267
- hasMore: offset + page.length < drafts.length,
4268
- drafts: page.map((draft) => truncateDraftBodyForResponse(redactDraftAttachmentsForListing(draft))),
4269
- }, false, page.map(draftSource));
4298
+ hasMore: offset + page.length < allDrafts.length,
4299
+ drafts: page.map(({ draft }) => truncateDraftBodyForResponse(redactDraftAttachmentsForListing(draft))),
4300
+ }, false, page.map(({ draft }) => draftSource(draft)));
4270
4301
  }
4271
4302
  case "list_remote_drafts": {
4272
4303
  // Scope limitation (follow-up round): stays scoped to the primary account's
@@ -4304,7 +4335,11 @@ export function createServer(config, options = {}) {
4304
4335
  const to = args.to === undefined ? undefined : parseEmails(optionalString(args, "to"));
4305
4336
  const cc = args.cc === undefined ? undefined : parseEmails(optionalString(args, "cc"));
4306
4337
  const bcc = args.bcc === undefined ? undefined : parseEmails(optionalString(args, "bcc"));
4307
- const replyTo = optionalString(args, "replyTo");
4338
+ // body/replyTo use optionalClearableString, not optionalString — see its
4339
+ // comment — so an explicit body:'' or replyTo:'' actually clears the
4340
+ // field instead of being silently treated as "field not provided".
4341
+ const body = optionalClearableString(args, "body");
4342
+ const replyTo = optionalClearableString(args, "replyTo");
4308
4343
  const from = optionalString(args, "from");
4309
4344
  const priority = optionalString(args, "priority");
4310
4345
  const attachments = args.attachments === undefined ? undefined : optionalAttachmentList(args.attachments);
@@ -4329,7 +4364,7 @@ export function createServer(config, options = {}) {
4329
4364
  cc,
4330
4365
  bcc,
4331
4366
  subject: optionalString(args, "subject"),
4332
- body: optionalString(args, "body"),
4367
+ body,
4333
4368
  isHtml: typeof args.isHtml === "boolean" ? args.isHtml : undefined,
4334
4369
  priority: priority === "high" || priority === "low" || priority === "normal"
4335
4370
  ? priority
@@ -4401,6 +4436,22 @@ export function createServer(config, options = {}) {
4401
4436
  ensureSendAllowed(config.runtime);
4402
4437
  const { bundle: sendDraftBundle, rest: sendDraftIdRest } = resolveAccountForDraftId(requireString(args, "draftId"));
4403
4438
  const draft = await sendDraftBundle.draftStore.getDraft(sendDraftIdRest);
4439
+ // Found live (external review): sendDraftBundle above is resolved from
4440
+ // the draftId's own account-slug prefix — i.e. where the draft is
4441
+ // STORED — which never changes just because update_draft later set a
4442
+ // different draft.from. Repro: create_draft on primary, then
4443
+ // update_draft(from: secondary@example.com), then send_draft — the
4444
+ // payload correctly carries "secondary@example.com", but it went out
4445
+ // through PRIMARY's live SMTP connection with that as a header
4446
+ // override, which a Split-Addresses config (separate login per
4447
+ // address) can reject outright instead of accepting as a plain alias.
4448
+ // sendBundleForDraft is resolved separately, same distinction
4449
+ // send_email/reply_to_email already draw between "where to read/store"
4450
+ // and "which account to actually send through" — draft storage,
4451
+ // claimForSending, markSent, and remote-draft cleanup all still use
4452
+ // sendDraftBundle (the draft doesn't move), only the SMTP call itself
4453
+ // uses the resolved account.
4454
+ const sendBundleForDraft = draft.from ? accountManager.byAddress(draft.from) ?? sendDraftBundle : sendDraftBundle;
4404
4455
  // Found live: calling send_draft twice on the same draft sent it
4405
4456
  // twice — nothing here checked whether it was already marked
4406
4457
  // "sent" before sending again. Refuse; create_draft for a new
@@ -4468,7 +4519,7 @@ export function createServer(config, options = {}) {
4468
4519
  const sendStartedAt = Date.now();
4469
4520
  let result;
4470
4521
  try {
4471
- result = await sendDraftBundle.smtpService.sendEmail({
4522
+ result = await sendBundleForDraft.smtpService.sendEmail({
4472
4523
  to: draft.to,
4473
4524
  cc: draft.cc,
4474
4525
  bcc: draft.bcc,
@@ -4540,15 +4591,27 @@ export function createServer(config, options = {}) {
4540
4591
  : undefined,
4541
4592
  };
4542
4593
  sentDraft = presentDraft(sendDraftBundle, remoteCleanup.draft);
4594
+ // Found live (external review): the send and markSent() above already
4595
+ // succeeded — this citation lookup is purely for the response's source
4596
+ // list, and used to throw uncaught if the original message had been
4597
+ // moved/deleted since the draft was created, reporting a false failure
4598
+ // for an email that had actually already been sent (and the draft
4599
+ // already marked "sent", so a caller who saw the error and retried
4600
+ // would correctly hit the already-sent guard above — but with no way to
4601
+ // know the first attempt had, in fact, worked). Best-effort, same as
4602
+ // verifySentCopy just below.
4543
4603
  const sources = [draftSource(sentDraft)];
4544
4604
  if (sentDraft.sourceEmailId) {
4545
- sources.push(emailSource(await sendDraftBundle.imapService.getEmailById(sentDraft.sourceEmailId)));
4605
+ try {
4606
+ sources.push(emailSource(await sendDraftBundle.imapService.getEmailById(sentDraft.sourceEmailId)));
4607
+ }
4608
+ catch (_) { }
4546
4609
  }
4547
4610
  let sentCopyTokenDraft = "[sent-copy:unverified]";
4548
4611
  try {
4549
4612
  const verifyMsgId = result.messageId;
4550
4613
  if (verifyMsgId) {
4551
- const scv = await verifySentCopy(sendDraftBundle.imapService, verifyMsgId);
4614
+ const scv = await verifySentCopy(sendBundleForDraft.imapService, verifyMsgId);
4552
4615
  if (scv.found)
4553
4616
  sentCopyTokenDraft = "[sent-copy:verified]";
4554
4617
  }
@@ -4570,6 +4633,13 @@ export function createServer(config, options = {}) {
4570
4633
  ensureSendAllowed(config.runtime);
4571
4634
  const { bundle: scheduleDraftBundle, rest: scheduleDraftIdRest } = resolveAccountForDraftId(requireString(args, "draftId"));
4572
4635
  const draft = await scheduleDraftBundle.draftStore.getDraft(scheduleDraftIdRest);
4636
+ // Same fix as send_draft's identical sendBundleForDraft — see its
4637
+ // comment. The delivery queue fires via whichever account's own
4638
+ // deliveryQueueService it was enqueued into, so this must be the
4639
+ // account draft.from actually names, not always the draft's storage
4640
+ // account — used consistently below for both the duplicate-schedule
4641
+ // check and the enqueue call itself, so both look at the same queue.
4642
+ const sendBundleForScheduleDraft = draft.from ? accountManager.byAddress(draft.from) ?? scheduleDraftBundle : scheduleDraftBundle;
4573
4643
  // Mirrors send_draft's own pendingScheduled guard, for the reverse
4574
4644
  // ordering: send_draft already delivered this draft once, and
4575
4645
  // scheduling it again here would deliver it a second time at
@@ -4594,9 +4664,9 @@ export function createServer(config, options = {}) {
4594
4664
  // duplicate. This external check is kept only as an optional
4595
4665
  // early-exit optimization to avoid a wasted round-trip to the lock
4596
4666
  // in the common non-racing case.
4597
- const alreadyPendingScheduled = (await scheduleDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
4667
+ const alreadyPendingScheduled = (await sendBundleForScheduleDraft.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
4598
4668
  if (alreadyPendingScheduled) {
4599
- throw new McpError(ErrorCode.InvalidParams, `This draft already has a pending scheduled send (id ${withAccountPrefix(responseSlug(scheduleDraftBundle), alreadyPendingScheduled.id)}, sendAt ${alreadyPendingScheduled.sendAt}). Scheduling it again would deliver it twice. Cancel the existing one with cancel_send first if you want a different sendAt.`);
4669
+ throw new McpError(ErrorCode.InvalidParams, `This draft already has a pending scheduled send (id ${withAccountPrefix(responseSlug(sendBundleForScheduleDraft), alreadyPendingScheduled.id)}, sendAt ${alreadyPendingScheduled.sendAt}). Scheduling it again would deliver it twice. Cancel the existing one with cancel_send first if you want a different sendAt.`);
4600
4670
  }
4601
4671
  const sendAt = requireString(args, "sendAt");
4602
4672
  const sendAtTime = new Date(sendAt).getTime();
@@ -4620,7 +4690,7 @@ export function createServer(config, options = {}) {
4620
4690
  if (external.length > 0)
4621
4691
  throw new McpError(ErrorCode.InvalidParams, "PROTONMAIL_RESTRICT_OUTBOUND_TO_SELF is enabled. All recipients must be the authenticated user.");
4622
4692
  }
4623
- const queued = await withAudit(auditService, name, args, async () => scheduleDraftBundle.deliveryQueueService.enqueue({
4693
+ const queued = await withAudit(auditService, name, args, async () => sendBundleForScheduleDraft.deliveryQueueService.enqueue({
4624
4694
  to: draft.to,
4625
4695
  cc: draft.cc,
4626
4696
  bcc: draft.bcc,
@@ -4639,7 +4709,7 @@ export function createServer(config, options = {}) {
4639
4709
  }, new Date(sendAtTime).toISOString(), "scheduled_send", draft.id));
4640
4710
  return createTextResult({
4641
4711
  queued: true,
4642
- id: withAccountPrefix(responseSlug(scheduleDraftBundle), queued.id),
4712
+ id: withAccountPrefix(responseSlug(sendBundleForScheduleDraft), queued.id),
4643
4713
  draftId: withAccountPrefix(responseSlug(scheduleDraftBundle), draft.id),
4644
4714
  sendAt: queued.sendAt,
4645
4715
  note: "The draft's content was snapshotted now and queued. This server must stay running for the send to fire at sendAt — if restarted first, it fires on next startup instead. The draft record itself is not automatically marked sent; use cancel_send with this id to abort before it fires.",
@@ -4770,10 +4840,20 @@ export function createServer(config, options = {}) {
4770
4840
  }
4771
4841
  const preferHtml = normalizeBoolean(args.preferHtml, false);
4772
4842
  const maxBodyLength = normalizeLimit(args.maxBodyLength, undefined, 1, 500_000);
4843
+ // Found live (external review): every id went straight to the primary
4844
+ // account's imapService regardless of its own "<slug>::" prefix, so a
4845
+ // non-primary account's id in the list either resolved the wrong
4846
+ // message on primary or (more often) just failed to be found — the
4847
+ // list [primaryId, secondary::id] reported succeeded:1 instead of 2.
4848
+ // Each id is now resolved to its own account first, same as every
4849
+ // other emailId-taking tool, and the returned id keeps its prefix.
4773
4850
  const results = await Promise.all(emailIds.map(async (emailId) => {
4774
4851
  try {
4775
- const detail = await imapService.getEmailById(emailId);
4776
- return { emailId, ok: true, email: formatEmailDetailOutput(detail, preferHtml, maxBodyLength) };
4852
+ const { bundle, rest } = resolveAccountForEmailId(emailId);
4853
+ const detail = await bundle.imapService.getEmailById(rest);
4854
+ const output = formatEmailDetailOutput(detail, preferHtml, maxBodyLength);
4855
+ output.id = prefixedIdFor(bundle, detail.id);
4856
+ return { emailId, ok: true, email: output };
4777
4857
  }
4778
4858
  catch (error) {
4779
4859
  return { emailId, ok: false, error: error instanceof Error ? error.message : String(error) };