proton-mail-bridge-client 2.1.6 → 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;
@@ -65,6 +66,9 @@ export declare function redactDraftAttachmentsForListing(draft: DraftRecord): {
65
66
  remoteDraft?: import("./types/index.js").RemoteDraftRef;
66
67
  lastSendResult?: import("./types/index.js").DraftSendResult;
67
68
  };
69
+ export declare function truncateDraftBodyForResponse<T extends {
70
+ body: string;
71
+ }>(draft: T): T;
68
72
  export declare function redactQueueRecordAttachments<T extends {
69
73
  payload: SendEmailInput;
70
74
  }>(record: T): T;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -385,12 +385,14 @@ const TOOLS = [
385
385
  },
386
386
  {
387
387
  name: "list_drafts",
388
- description: "List all locally saved drafts with their status, subject, and timestamps. Attachment content is omitted here (filename/type/size only) to keep this unbounded listing from blowing up on large attachments — use get_draft for the full attachment content of one draft. Use to review in-progress or unsent messages. Does NOT list drafts stored only on the Proton server — use list_remote_drafts for those. Prefer get_draft when you already have a draftId and need the full content.",
388
+ description: "List all locally saved drafts with their status, subject, and timestamps. Attachment content is omitted here (filename/type/size only) — use get_draft for the full attachment content of one draft. Each draft's body is also truncated to a short preview (bodyTruncated:true, bodyLength gives the real size) — use get_draft for the full body of one draft. Use to review in-progress or unsent messages. Does NOT list drafts stored only on the Proton server — use list_remote_drafts for those. Prefer get_draft when you already have a draftId and need the full content.",
389
389
  annotations: { readOnlyHint: true },
390
390
  inputSchema: {
391
391
  type: "object",
392
392
  properties: {
393
393
  includeSent: { type: "boolean", description: "Include drafts already sent.", default: false },
394
+ limit: { type: "number", description: "Maximum drafts to return. Omit to return every draft (still with truncated bodies/attachments) — set this when you only need a bounded page." },
395
+ offset: { type: "number", description: "Number of drafts to skip, newest-updated first.", default: 0 },
394
396
  },
395
397
  },
396
398
  },
@@ -1839,6 +1841,23 @@ function optionalString(args, key) {
1839
1841
  const value = args[key];
1840
1842
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
1841
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
+ }
1842
1861
  // Shared by get_email_by_id and get_emails_by_ids: applies body preference/
1843
1862
  // truncation and strips the full raw header map by default (needless token
1844
1863
  // bloat — get_email_by_id re-adds it when showHeaders is explicitly true).
@@ -2277,6 +2296,34 @@ export function redactDraftAttachmentsForListing(draft) {
2277
2296
  })),
2278
2297
  };
2279
2298
  }
2299
+ // How much of a draft's body survives into a mutation/listing response before
2300
+ // it's truncated — long enough to recognize the draft at a glance, short
2301
+ // enough that a large body (a long thread quoted into a reply, a big pasted
2302
+ // table) doesn't dominate the response the way redactDraftAttachmentsForListing
2303
+ // already stops a base64 attachment from doing.
2304
+ const DRAFT_BODY_PREVIEW_LENGTH = 500;
2305
+ // Found live (external review, repeated across three rounds): create_draft,
2306
+ // update_draft, create_reply_draft, create_forward_draft,
2307
+ // create_thread_reply_draft, sync_draft_to_remote, and list_drafts all echoed
2308
+ // the draft's COMPLETE body back — confirmed live at ~40k tokens for a single
2309
+ // update_draft call that only changed the subject of a ~202 KB draft, and
2310
+ // ~261k tokens for list_drafts with 107 drafts. The caller supplied the body
2311
+ // itself (create/update) or already has it from a prior call (list) — same
2312
+ // "caller already knows what it sent" reasoning as attachment redaction, just
2313
+ // for the text body instead of base64. get_draft deliberately keeps the full
2314
+ // body — it's the "let me look at this one draft" call, not a repeated-edit
2315
+ // or bulk-listing one.
2316
+ export function truncateDraftBodyForResponse(draft) {
2317
+ if (draft.body.length <= DRAFT_BODY_PREVIEW_LENGTH) {
2318
+ return draft;
2319
+ }
2320
+ return {
2321
+ ...draft,
2322
+ body: `${draft.body.slice(0, DRAFT_BODY_PREVIEW_LENGTH)}…`,
2323
+ bodyLength: draft.body.length,
2324
+ bodyTruncated: true,
2325
+ };
2326
+ }
2280
2327
  // Same fix as redactDraftAttachmentsForListing, for list_scheduled_sends — a
2281
2328
  // queued send's payload carries the same full base64 EmailAttachmentInput
2282
2329
  // content (found live: ~955k tokens for a single 1 MiB attachment on ONE
@@ -2459,7 +2506,7 @@ async function syncDraftToRemote(draftStore, smtpService, imapService, draft) {
2459
2506
  // signature into the draft body themselves, got it appended a second time
2460
2507
  // on every remote sync.
2461
2508
  appendSignature: false,
2462
- });
2509
+ }, /* preserveBcc */ true);
2463
2510
  const remoteDraft = await imapService.upsertRemoteDraft({
2464
2511
  raw,
2465
2512
  messageId: draft.draftMessageId,
@@ -4041,9 +4088,9 @@ export function createServer(config, options = {}) {
4041
4088
  return presentDraft(createDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
4042
4089
  });
4043
4090
  // Same fix as update_draft's identical bloat — see its comment. The
4044
- // caller already knows what it attached; get_draft is the "let me see
4045
- // the full attachment content" call, not this one.
4046
- return createTextResult(redactDraftAttachmentsForListing(result), false, [
4091
+ // caller already knows what it attached (and what body it just sent);
4092
+ // get_draft is the "let me see the full content" call, not this one.
4093
+ return createTextResult(truncateDraftBodyForResponse(redactDraftAttachmentsForListing(result)), false, [
4047
4094
  draftSource(result),
4048
4095
  ...(result.remoteDraft?.emailId
4049
4096
  ? [
@@ -4126,7 +4173,7 @@ export function createServer(config, options = {}) {
4126
4173
  return presentDraft(createReplyDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
4127
4174
  });
4128
4175
  // Same fix as create_draft/update_draft's identical bloat — see their comments.
4129
- return createTextResult(redactDraftAttachmentsForListing(result), false, [
4176
+ return createTextResult(truncateDraftBodyForResponse(redactDraftAttachmentsForListing(result)), false, [
4130
4177
  draftSource(result),
4131
4178
  emailSource(detail),
4132
4179
  ...(result.remoteDraft?.emailId
@@ -4201,7 +4248,7 @@ export function createServer(config, options = {}) {
4201
4248
  return presentDraft(createForwardDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
4202
4249
  });
4203
4250
  // Same fix as create_draft/update_draft's identical bloat — see their comments.
4204
- return createTextResult(redactDraftAttachmentsForListing(result), false, [
4251
+ return createTextResult(truncateDraftBodyForResponse(redactDraftAttachmentsForListing(result)), false, [
4205
4252
  draftSource(result),
4206
4253
  emailSource(detail),
4207
4254
  ...(result.remoteDraft?.emailId
@@ -4216,14 +4263,41 @@ export function createServer(config, options = {}) {
4216
4263
  ]);
4217
4264
  }
4218
4265
  case "list_drafts": {
4219
- // Scope limitation (follow-up round): stays scoped to the primary account's
4220
- // drafts only — a non-primary account's drafts aren't listed here yet. Use
4221
- // get_draft with that account's prefixed draftId if you already have one.
4222
- 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));
4284
+ // Found live (external review): every draft's full body was returned
4285
+ // alongside the already-redacted attachments — confirmed live at ~261k
4286
+ // tokens for 107 drafts. Bodies are now truncated the same way a
4287
+ // mutation response's body is (see truncateDraftBodyForResponse). limit/
4288
+ // offset are optional and additive — omitting them still returns every
4289
+ // draft (just with truncated bodies/attachments), so this doesn't change
4290
+ // behavior for a caller not using the new params.
4291
+ const offset = normalizeLimit(args.offset, 0, 0, 10_000);
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);
4223
4294
  return createTextResult({
4224
- total: drafts.length,
4225
- drafts: drafts.map(redactDraftAttachmentsForListing),
4226
- }, false, drafts.map(draftSource));
4295
+ total: allDrafts.length,
4296
+ offset,
4297
+ returned: page.length,
4298
+ hasMore: offset + page.length < allDrafts.length,
4299
+ drafts: page.map(({ draft }) => truncateDraftBodyForResponse(redactDraftAttachmentsForListing(draft))),
4300
+ }, false, page.map(({ draft }) => draftSource(draft)));
4227
4301
  }
4228
4302
  case "list_remote_drafts": {
4229
4303
  // Scope limitation (follow-up round): stays scoped to the primary account's
@@ -4261,7 +4335,11 @@ export function createServer(config, options = {}) {
4261
4335
  const to = args.to === undefined ? undefined : parseEmails(optionalString(args, "to"));
4262
4336
  const cc = args.cc === undefined ? undefined : parseEmails(optionalString(args, "cc"));
4263
4337
  const bcc = args.bcc === undefined ? undefined : parseEmails(optionalString(args, "bcc"));
4264
- 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");
4265
4343
  const from = optionalString(args, "from");
4266
4344
  const priority = optionalString(args, "priority");
4267
4345
  const attachments = args.attachments === undefined ? undefined : optionalAttachmentList(args.attachments);
@@ -4286,7 +4364,7 @@ export function createServer(config, options = {}) {
4286
4364
  cc,
4287
4365
  bcc,
4288
4366
  subject: optionalString(args, "subject"),
4289
- body: optionalString(args, "body"),
4367
+ body,
4290
4368
  isHtml: typeof args.isHtml === "boolean" ? args.isHtml : undefined,
4291
4369
  priority: priority === "high" || priority === "low" || priority === "normal"
4292
4370
  ? priority
@@ -4314,10 +4392,13 @@ export function createServer(config, options = {}) {
4314
4392
  // subject/body — the attachments usually aren't touched by this call at all, so
4315
4393
  // re-echoing their full base64 `content` (serialized twice by createTextResult,
4316
4394
  // same issue redactDraftAttachmentsForListing already fixed for list_drafts)
4317
- // burns tokens on every single edit for a draft that has any attachment. The
4318
- // caller already knows what it attached; get_draft remains full-content for the
4319
- // "let me look at this draft" case, which is a single call rather than a loop.
4320
- return createTextResult(redactDraftAttachmentsForListing(result), false, [
4395
+ // burns tokens on every single edit for a draft that has any attachment. Same
4396
+ // reasoning for the body itself confirmed live at ~40k tokens for a single
4397
+ // subject-only edit of a ~202 KB draft (see truncateDraftBodyForResponse). The
4398
+ // caller already knows what it attached and what body it just sent; get_draft
4399
+ // remains full-content for the "let me look at this draft" case, which is a
4400
+ // single call rather than a loop.
4401
+ return createTextResult(truncateDraftBodyForResponse(redactDraftAttachmentsForListing(result)), false, [
4321
4402
  draftSource(result),
4322
4403
  ...(result.remoteDraft?.emailId
4323
4404
  ? [
@@ -4337,7 +4418,7 @@ export function createServer(config, options = {}) {
4337
4418
  const synced = await withAudit(auditService, name, args, async () => syncDraftToRemote(syncDraftBundle.draftStore, syncDraftBundle.smtpService, syncDraftBundle.imapService, draft));
4338
4419
  const presentedSynced = presentDraft(syncDraftBundle, synced.draft);
4339
4420
  // Same fix as create_draft/update_draft's identical bloat — see their comments.
4340
- return createTextResult({ ...redactDraftAttachmentsForListing(presentedSynced), remoteSync: synced.remoteSync }, false, [
4421
+ return createTextResult({ ...truncateDraftBodyForResponse(redactDraftAttachmentsForListing(presentedSynced)), remoteSync: synced.remoteSync }, false, [
4341
4422
  draftSource(presentedSynced),
4342
4423
  ...(presentedSynced.remoteDraft?.emailId
4343
4424
  ? [
@@ -4355,6 +4436,22 @@ export function createServer(config, options = {}) {
4355
4436
  ensureSendAllowed(config.runtime);
4356
4437
  const { bundle: sendDraftBundle, rest: sendDraftIdRest } = resolveAccountForDraftId(requireString(args, "draftId"));
4357
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;
4358
4455
  // Found live: calling send_draft twice on the same draft sent it
4359
4456
  // twice — nothing here checked whether it was already marked
4360
4457
  // "sent" before sending again. Refuse; create_draft for a new
@@ -4422,7 +4519,7 @@ export function createServer(config, options = {}) {
4422
4519
  const sendStartedAt = Date.now();
4423
4520
  let result;
4424
4521
  try {
4425
- result = await sendDraftBundle.smtpService.sendEmail({
4522
+ result = await sendBundleForDraft.smtpService.sendEmail({
4426
4523
  to: draft.to,
4427
4524
  cc: draft.cc,
4428
4525
  bcc: draft.bcc,
@@ -4494,15 +4591,27 @@ export function createServer(config, options = {}) {
4494
4591
  : undefined,
4495
4592
  };
4496
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.
4497
4603
  const sources = [draftSource(sentDraft)];
4498
4604
  if (sentDraft.sourceEmailId) {
4499
- sources.push(emailSource(await sendDraftBundle.imapService.getEmailById(sentDraft.sourceEmailId)));
4605
+ try {
4606
+ sources.push(emailSource(await sendDraftBundle.imapService.getEmailById(sentDraft.sourceEmailId)));
4607
+ }
4608
+ catch (_) { }
4500
4609
  }
4501
4610
  let sentCopyTokenDraft = "[sent-copy:unverified]";
4502
4611
  try {
4503
4612
  const verifyMsgId = result.messageId;
4504
4613
  if (verifyMsgId) {
4505
- const scv = await verifySentCopy(sendDraftBundle.imapService, verifyMsgId);
4614
+ const scv = await verifySentCopy(sendBundleForDraft.imapService, verifyMsgId);
4506
4615
  if (scv.found)
4507
4616
  sentCopyTokenDraft = "[sent-copy:verified]";
4508
4617
  }
@@ -4524,6 +4633,13 @@ export function createServer(config, options = {}) {
4524
4633
  ensureSendAllowed(config.runtime);
4525
4634
  const { bundle: scheduleDraftBundle, rest: scheduleDraftIdRest } = resolveAccountForDraftId(requireString(args, "draftId"));
4526
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;
4527
4643
  // Mirrors send_draft's own pendingScheduled guard, for the reverse
4528
4644
  // ordering: send_draft already delivered this draft once, and
4529
4645
  // scheduling it again here would deliver it a second time at
@@ -4548,9 +4664,9 @@ export function createServer(config, options = {}) {
4548
4664
  // duplicate. This external check is kept only as an optional
4549
4665
  // early-exit optimization to avoid a wasted round-trip to the lock
4550
4666
  // in the common non-racing case.
4551
- 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");
4552
4668
  if (alreadyPendingScheduled) {
4553
- 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.`);
4554
4670
  }
4555
4671
  const sendAt = requireString(args, "sendAt");
4556
4672
  const sendAtTime = new Date(sendAt).getTime();
@@ -4574,7 +4690,7 @@ export function createServer(config, options = {}) {
4574
4690
  if (external.length > 0)
4575
4691
  throw new McpError(ErrorCode.InvalidParams, "PROTONMAIL_RESTRICT_OUTBOUND_TO_SELF is enabled. All recipients must be the authenticated user.");
4576
4692
  }
4577
- const queued = await withAudit(auditService, name, args, async () => scheduleDraftBundle.deliveryQueueService.enqueue({
4693
+ const queued = await withAudit(auditService, name, args, async () => sendBundleForScheduleDraft.deliveryQueueService.enqueue({
4578
4694
  to: draft.to,
4579
4695
  cc: draft.cc,
4580
4696
  bcc: draft.bcc,
@@ -4593,7 +4709,7 @@ export function createServer(config, options = {}) {
4593
4709
  }, new Date(sendAtTime).toISOString(), "scheduled_send", draft.id));
4594
4710
  return createTextResult({
4595
4711
  queued: true,
4596
- id: withAccountPrefix(responseSlug(scheduleDraftBundle), queued.id),
4712
+ id: withAccountPrefix(responseSlug(sendBundleForScheduleDraft), queued.id),
4597
4713
  draftId: withAccountPrefix(responseSlug(scheduleDraftBundle), draft.id),
4598
4714
  sendAt: queued.sendAt,
4599
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.",
@@ -4657,25 +4773,34 @@ export function createServer(config, options = {}) {
4657
4773
  // requested offset/limit to that merged superset instead of per account.
4658
4774
  const primarySlug = accountManager.primary().account.slug;
4659
4775
  const requestedOffset = getEmailsInput.offset ?? 0;
4660
- const perAccountFetchLimit = requestedOffset + effectiveLimit;
4776
+ // +1 beyond what's strictly needed to fill the page: whether that extra
4777
+ // item actually comes back is what tells us hasMore, independent of any
4778
+ // filter (beforeUid etc.) or of how the raw mailbox total compares to the
4779
+ // page window. Two things were tried and each broke a different case:
4780
+ // (1) using mergedAll.length > offset+limit as hasMore, with NO extra
4781
+ // overfetch — a lopsided distribution (e.g. 300 messages in one account,
4782
+ // 0 in another) fetches exactly offset+limit from the big account, so
4783
+ // mergedAll.length can never exceed that window even though hundreds
4784
+ // more exist, reporting hasMore:false wrongly.
4785
+ // (2) using the raw summed mailbox `total` for hasMore instead — correct
4786
+ // for that case, but wrong under a restrictive filter like beforeUid:
4787
+ // `total` is the UNFILTERED mailbox message count, not the count of
4788
+ // messages actually matching the filter, so a beforeUid narrowing 300
4789
+ // messages down to 2 matches still compared against total=300 and
4790
+ // reported hasMore:true with nothing left to page to.
4791
+ // Fetching one extra item fixes both: it directly observes whether a
4792
+ // NEXT matching item exists, under whatever filter is active, rather
4793
+ // than inferring it from a count that can be either capped-by-fetch or
4794
+ // filter-blind depending on which one you pick.
4795
+ const perAccountFetchLimit = requestedOffset + effectiveLimit + 1;
4661
4796
  const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
4662
4797
  const result = await bundle.imapService.getEmails({ ...getEmailsInput, offset: 0, limit: perAccountFetchLimit });
4663
4798
  return { bundle, result };
4664
4799
  }));
4665
4800
  const taggedEmails = perAccount.flatMap(({ bundle, result }) => result.emails.map((email) => ({ ...email, id: withAccountPrefix(accountSlugForTag(bundle, primarySlug), email.id) })));
4666
4801
  const mergedAll = sortByDateDesc(taggedEmails, (email) => email.internalDate || email.date);
4667
- const { page: merged } = paginateMergedAccountResults(mergedAll, requestedOffset, effectiveLimit);
4802
+ const { page: merged, hasMore } = paginateMergedAccountResults(mergedAll, requestedOffset, effectiveLimit);
4668
4803
  const total = perAccount.reduce((sum, { result }) => sum + result.total, 0);
4669
- // Found live (external review): paginateMergedAccountResults' own hasMore
4670
- // only looks at what was actually FETCHED (mergedAll.length, capped at
4671
- // perAccountFetchLimit per account) — with a lopsided distribution (e.g.
4672
- // 300 messages in one account, 0 in another) and a small page near the
4673
- // start, mergedAll.length can equal requestedOffset+limit exactly even
4674
- // though the account's real total is far larger, reporting hasMore:false
4675
- // while 275 more messages exist. `total` above is each account's real,
4676
- // authoritative IMAP mailbox count (not bounded by what we fetched), so
4677
- // it's the correct source for this instead.
4678
- const hasMore = total > requestedOffset + effectiveLimit;
4679
4804
  return createTextResult({
4680
4805
  folder: getEmailsInput.folder?.trim() || "INBOX",
4681
4806
  total,
@@ -4715,10 +4840,20 @@ export function createServer(config, options = {}) {
4715
4840
  }
4716
4841
  const preferHtml = normalizeBoolean(args.preferHtml, false);
4717
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.
4718
4850
  const results = await Promise.all(emailIds.map(async (emailId) => {
4719
4851
  try {
4720
- const detail = await imapService.getEmailById(emailId);
4721
- 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 };
4722
4857
  }
4723
4858
  catch (error) {
4724
4859
  return { emailId, ok: false, error: error instanceof Error ? error.message : String(error) };
@@ -6537,7 +6672,7 @@ export function createServer(config, options = {}) {
6537
6672
  return { ...presented, threadId: thread.id };
6538
6673
  });
6539
6674
  // Same fix as create_draft/update_draft's identical bloat — see their comments.
6540
- return createTextResult(redactDraftAttachmentsForListing(result), false, [
6675
+ return createTextResult(truncateDraftBodyForResponse(redactDraftAttachmentsForListing(result)), false, [
6541
6676
  draftSource(result),
6542
6677
  threadSource(thread),
6543
6678
  emailSource(detail),