proton-mail-bridge-client 2.1.2 → 2.1.3
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/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +130 -43
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -63,6 +63,10 @@ export declare function redactDraftAttachmentsForListing(draft: DraftRecord): {
|
|
|
63
63
|
remoteDraft?: import("./types/index.js").RemoteDraftRef;
|
|
64
64
|
lastSendResult?: import("./types/index.js").DraftSendResult;
|
|
65
65
|
};
|
|
66
|
+
export declare function paginateMergedAccountResults<T>(sortedMerged: T[], offset: number, limit: number): {
|
|
67
|
+
page: T[];
|
|
68
|
+
hasMore: boolean;
|
|
69
|
+
};
|
|
66
70
|
export declare function getBulkMaxBatchSize(args: Record<string, unknown>): number;
|
|
67
71
|
export declare function ensureBulkBatchSize(uidsLength: number, max: number): void;
|
|
68
72
|
interface BulkExcludedEmailId {
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,EAAkB,WAAW,EAAE,MAAM,4BAA4B,CAAC;AACzE,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAE7D,OAAO,KAAK,EAMV,mBAAmB,EAEnB,WAAW,EAIX,WAAW,EAIX,gBAAgB,EAIjB,MAAM,kBAAkB,CAAC;AAmB1B,OAAO,EAAE,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAmrDnF,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;AA4KD,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;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -2495,6 +2495,23 @@ function sortByDateDesc(items, dateOf) {
|
|
|
2495
2495
|
return rightTime - leftTime;
|
|
2496
2496
|
});
|
|
2497
2497
|
}
|
|
2498
|
+
// get_emails' multi-account offset/limit pagination math, pulled out for direct
|
|
2499
|
+
// testing. Applying `offset` separately to EACH account's own page before merging
|
|
2500
|
+
// is wrong — page 2 (offset=25,limit=25) would skip each account's own first 25
|
|
2501
|
+
// messages independently, then concatenate their page-2 results, which drops
|
|
2502
|
+
// items relative to page 2 of the globally-merged, newest-first timeline (found
|
|
2503
|
+
// live with interleaved messages across two accounts: page 2 skipped the
|
|
2504
|
+
// second-newest message overall). The caller is expected to have already fetched
|
|
2505
|
+
// each account from offset 0 up to (offset + limit) — since every account's own
|
|
2506
|
+
// list already arrives newest-first, that's guaranteed to include every item that
|
|
2507
|
+
// could rank in the global top (offset + limit) — and passes the sorted,
|
|
2508
|
+
// concatenated superset in here to be paginated as one timeline.
|
|
2509
|
+
export function paginateMergedAccountResults(sortedMerged, offset, limit) {
|
|
2510
|
+
return {
|
|
2511
|
+
page: sortedMerged.slice(offset, offset + limit),
|
|
2512
|
+
hasMore: sortedMerged.length > offset + limit,
|
|
2513
|
+
};
|
|
2514
|
+
}
|
|
2498
2515
|
function mergeMailboxLabels(perAccountLabels) {
|
|
2499
2516
|
const merged = new Map();
|
|
2500
2517
|
for (const labels of perAccountLabels) {
|
|
@@ -3170,6 +3187,12 @@ export function createServer(config, options = {}) {
|
|
|
3170
3187
|
// string-prefix stripping over the non-primary slugs), so draftId resolution reuses
|
|
3171
3188
|
// resolveAccountForEmailId itself rather than a second, parallel mechanism.
|
|
3172
3189
|
const resolveAccountForDraftId = resolveAccountForEmailId;
|
|
3190
|
+
// Same scheme again for delivery-queue ids (cancel_send/list_scheduled_sends) and
|
|
3191
|
+
// snooze ids (cancel_snooze/list_snoozed) — both are per-account services (their own
|
|
3192
|
+
// JSON file under that account's dataDir), so a queued/snoozed item's id needs the
|
|
3193
|
+
// same slug-prefix round-trip as an emailId/draftId to be found again by account.
|
|
3194
|
+
const resolveAccountForQueueId = resolveAccountForEmailId;
|
|
3195
|
+
const resolveAccountForSnoozeId = resolveAccountForEmailId;
|
|
3173
3196
|
// The slug to prefix a response id with for this bundle — undefined for the
|
|
3174
3197
|
// primary account, so a primary-account id round-trips with NO prefix at all,
|
|
3175
3198
|
// exactly as it did before multi-account support existed.
|
|
@@ -3429,22 +3452,6 @@ export function createServer(config, options = {}) {
|
|
|
3429
3452
|
}
|
|
3430
3453
|
undoWindowSeconds = requested;
|
|
3431
3454
|
}
|
|
3432
|
-
if (undoWindowSeconds > 0) {
|
|
3433
|
-
// Scope limitation (follow-up round): the undo-send queue is always the
|
|
3434
|
-
// primary's deliveryQueueService, so a delayed send with a non-primary
|
|
3435
|
-
// `from` still only gets the header-override-on-primary fallback at fire
|
|
3436
|
-
// time, not this account's own SMTP connection — unlike the immediate-send
|
|
3437
|
-
// path above. Fine for now since `from` is still passed through in the
|
|
3438
|
-
// payload and Proton accepts any verified address on the account.
|
|
3439
|
-
const sendAt = new Date(Date.now() + undoWindowSeconds * 1000).toISOString();
|
|
3440
|
-
const queued = await withAudit(auditService, name, args, async () => deliveryQueueService.enqueue(emailPayload, sendAt, "undo_send"));
|
|
3441
|
-
return createTextResult({
|
|
3442
|
-
queued: true,
|
|
3443
|
-
id: queued.id,
|
|
3444
|
-
sendAt: queued.sendAt,
|
|
3445
|
-
note: `Not sent yet — will send in ~${undoWindowSeconds}s unless canceled with cancel_send. This server must stay running for the send to fire; if it's restarted before sendAt, the send fires on next startup instead.`,
|
|
3446
|
-
});
|
|
3447
|
-
}
|
|
3448
3455
|
// A `from` matching a configured additional account routes through that
|
|
3449
3456
|
// account's own SMTP connection — required under Bridge's Split Addresses
|
|
3450
3457
|
// feature, where each address is its own separate IMAP/SMTP login, not just
|
|
@@ -3453,8 +3460,26 @@ export function createServer(config, options = {}) {
|
|
|
3453
3460
|
// the primary connection with `from` passed through as a plain header
|
|
3454
3461
|
// override (still valid for a genuine alias only reachable that way).
|
|
3455
3462
|
const sendBundle = from ? accountManager.byAddress(from) : undefined;
|
|
3463
|
+
const effectiveSendBundle = sendBundle ?? primaryBundle;
|
|
3456
3464
|
const sendSmtp = sendBundle ? sendBundle.smtpService : smtpService;
|
|
3457
3465
|
const sendImap = sendBundle ? sendBundle.imapService : imapService;
|
|
3466
|
+
if (undoWindowSeconds > 0) {
|
|
3467
|
+
// The undo-send queue now goes through the resolved account's OWN
|
|
3468
|
+
// deliveryQueueService (same routing as the immediate-send path above),
|
|
3469
|
+
// so it fires later via that account's real SMTP connection rather than
|
|
3470
|
+
// always the primary's — a delayed send with a non-primary `from` used
|
|
3471
|
+
// to only ever get the header-override-on-primary fallback at fire time.
|
|
3472
|
+
// The returned id is prefixed with that account's slug (same scheme as
|
|
3473
|
+
// emailId/draftId) so cancel_send/list_scheduled_sends can find it again.
|
|
3474
|
+
const sendAt = new Date(Date.now() + undoWindowSeconds * 1000).toISOString();
|
|
3475
|
+
const queued = await withAudit(auditService, name, args, async () => effectiveSendBundle.deliveryQueueService.enqueue(emailPayload, sendAt, "undo_send"));
|
|
3476
|
+
return createTextResult({
|
|
3477
|
+
queued: true,
|
|
3478
|
+
id: withAccountPrefix(responseSlug(effectiveSendBundle), queued.id),
|
|
3479
|
+
sendAt: queued.sendAt,
|
|
3480
|
+
note: `Not sent yet — will send in ~${undoWindowSeconds}s unless canceled with cancel_send. This server must stay running for the send to fire; if it's restarted before sendAt, the send fires on next startup instead.`,
|
|
3481
|
+
});
|
|
3482
|
+
}
|
|
3458
3483
|
const result = await withAudit(auditService, name, args, async () => sendSmtp.sendEmail(emailPayload));
|
|
3459
3484
|
let sentCopyTokenSend = "[sent-copy:unverified]";
|
|
3460
3485
|
try {
|
|
@@ -3475,13 +3500,31 @@ export function createServer(config, options = {}) {
|
|
|
3475
3500
|
});
|
|
3476
3501
|
}
|
|
3477
3502
|
case "cancel_send": {
|
|
3478
|
-
|
|
3479
|
-
|
|
3503
|
+
// The queue id carries an account-slug prefix when the item was enqueued
|
|
3504
|
+
// through a non-primary account (see send_email/schedule_draft) — resolve
|
|
3505
|
+
// it back to that account's own deliveryQueueService, same as an
|
|
3506
|
+
// emailId/draftId, instead of always checking the primary's queue.
|
|
3507
|
+
const { bundle: cancelSendBundle, rest: cancelSendIdRest } = resolveAccountForQueueId(requireString(args, "id"));
|
|
3508
|
+
const result = await withAudit(auditService, name, args, async () => cancelSendBundle.deliveryQueueService.cancel(cancelSendIdRest));
|
|
3509
|
+
return createTextResult({ ...result, id: withAccountPrefix(responseSlug(cancelSendBundle), result.id) });
|
|
3480
3510
|
}
|
|
3481
3511
|
case "list_scheduled_sends": {
|
|
3482
3512
|
const statusFilter = optionalString(args, "status");
|
|
3483
|
-
|
|
3484
|
-
|
|
3513
|
+
// Fan out across every configured account's own delivery queue and tag each
|
|
3514
|
+
// item's id with its owning account's slug — mirrors the read-tool fan-out
|
|
3515
|
+
// pattern (get_emails etc.). Single-account guard keeps byte-identical output
|
|
3516
|
+
// for the common case.
|
|
3517
|
+
if (accountManager.all().length === 1) {
|
|
3518
|
+
const all = await withAudit(auditService, name, args, async () => deliveryQueueService.list());
|
|
3519
|
+
const filtered = statusFilter ? all.filter((item) => item.status === statusFilter) : all;
|
|
3520
|
+
return createTextResult(filtered);
|
|
3521
|
+
}
|
|
3522
|
+
const perAccount = await withAudit(auditService, name, args, async () => Promise.all(accountManager.all().map(async (bundle) => ({
|
|
3523
|
+
bundle,
|
|
3524
|
+
items: await bundle.deliveryQueueService.list(),
|
|
3525
|
+
}))));
|
|
3526
|
+
const tagged = perAccount.flatMap(({ bundle, items }) => items.map((item) => ({ ...item, id: withAccountPrefix(responseSlug(bundle), item.id) })));
|
|
3527
|
+
const filtered = statusFilter ? tagged.filter((item) => item.status === statusFilter) : tagged;
|
|
3485
3528
|
return createTextResult(filtered);
|
|
3486
3529
|
}
|
|
3487
3530
|
case "get_unsubscribe_info": {
|
|
@@ -4177,7 +4220,7 @@ export function createServer(config, options = {}) {
|
|
|
4177
4220
|
// immediate send is actually what's wanted.
|
|
4178
4221
|
const pendingScheduled = (await sendDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
|
|
4179
4222
|
if (pendingScheduled) {
|
|
4180
|
-
throw new McpError(ErrorCode.InvalidParams, `This draft already has a pending scheduled send (id ${pendingScheduled.id}, sendAt ${pendingScheduled.sendAt}). Sending now would deliver it twice. Cancel that scheduled send with cancel_send first, or wait for it to fire.`);
|
|
4223
|
+
throw new McpError(ErrorCode.InvalidParams, `This draft already has a pending scheduled send (id ${withAccountPrefix(responseSlug(sendDraftBundle), pendingScheduled.id)}, sendAt ${pendingScheduled.sendAt}). Sending now would deliver it twice. Cancel that scheduled send with cancel_send first, or wait for it to fire.`);
|
|
4181
4224
|
}
|
|
4182
4225
|
ensureValidEmails(draft.to, "to");
|
|
4183
4226
|
ensureValidEmails(draft.cc, "cc");
|
|
@@ -4358,7 +4401,7 @@ export function createServer(config, options = {}) {
|
|
|
4358
4401
|
// in the common non-racing case.
|
|
4359
4402
|
const alreadyPendingScheduled = (await scheduleDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
|
|
4360
4403
|
if (alreadyPendingScheduled) {
|
|
4361
|
-
throw new McpError(ErrorCode.InvalidParams, `This draft already has a pending scheduled send (id ${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.`);
|
|
4404
|
+
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.`);
|
|
4362
4405
|
}
|
|
4363
4406
|
const sendAt = requireString(args, "sendAt");
|
|
4364
4407
|
const sendAtTime = new Date(sendAt).getTime();
|
|
@@ -4401,7 +4444,7 @@ export function createServer(config, options = {}) {
|
|
|
4401
4444
|
}, new Date(sendAtTime).toISOString(), "scheduled_send", draft.id));
|
|
4402
4445
|
return createTextResult({
|
|
4403
4446
|
queued: true,
|
|
4404
|
-
id: queued.id,
|
|
4447
|
+
id: withAccountPrefix(responseSlug(scheduleDraftBundle), queued.id),
|
|
4405
4448
|
draftId: withAccountPrefix(responseSlug(scheduleDraftBundle), draft.id),
|
|
4406
4449
|
sendAt: queued.sendAt,
|
|
4407
4450
|
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.",
|
|
@@ -4452,26 +4495,36 @@ export function createServer(config, options = {}) {
|
|
|
4452
4495
|
hasMore: result.emails.length === effectiveLimit,
|
|
4453
4496
|
}, false, result.emails.map(emailSource));
|
|
4454
4497
|
}
|
|
4455
|
-
// Merge strategy:
|
|
4456
|
-
//
|
|
4457
|
-
//
|
|
4458
|
-
//
|
|
4498
|
+
// Merge strategy: an `offset` applied separately to each account's own page
|
|
4499
|
+
// BEFORE merging is wrong — e.g. offset=25,limit=25 (page 2) would skip each
|
|
4500
|
+
// account's own first 25 messages independently, then concatenate their
|
|
4501
|
+
// page-2 results, which is not the same as page 2 of the globally-merged,
|
|
4502
|
+
// newest-first timeline (found live with interleaved messages across two
|
|
4503
|
+
// accounts: page 2 skipped the second-newest message overall). Fixed by
|
|
4504
|
+
// always fetching from offset 0 up to (requestedOffset + limit) from EVERY
|
|
4505
|
+
// account — enough that each account's true contribution to the global top
|
|
4506
|
+
// (requestedOffset + limit) is fully captured, since getEmails already
|
|
4507
|
+
// returns newest-first — then merging, re-sorting, and applying the
|
|
4508
|
+
// requested offset/limit to that merged superset instead of per account.
|
|
4459
4509
|
const primarySlug = accountManager.primary().account.slug;
|
|
4510
|
+
const requestedOffset = getEmailsInput.offset ?? 0;
|
|
4511
|
+
const perAccountFetchLimit = requestedOffset + effectiveLimit;
|
|
4460
4512
|
const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
|
|
4461
|
-
const result = await bundle.imapService.getEmails(getEmailsInput);
|
|
4513
|
+
const result = await bundle.imapService.getEmails({ ...getEmailsInput, offset: 0, limit: perAccountFetchLimit });
|
|
4462
4514
|
return { bundle, result };
|
|
4463
4515
|
}));
|
|
4464
4516
|
const taggedEmails = perAccount.flatMap(({ bundle, result }) => result.emails.map((email) => ({ ...email, id: withAccountPrefix(accountSlugForTag(bundle, primarySlug), email.id) })));
|
|
4465
|
-
const
|
|
4517
|
+
const mergedAll = sortByDateDesc(taggedEmails, (email) => email.internalDate || email.date);
|
|
4518
|
+
const { page: merged, hasMore } = paginateMergedAccountResults(mergedAll, requestedOffset, effectiveLimit);
|
|
4466
4519
|
const total = perAccount.reduce((sum, { result }) => sum + result.total, 0);
|
|
4467
4520
|
return createTextResult({
|
|
4468
4521
|
folder: getEmailsInput.folder?.trim() || "INBOX",
|
|
4469
4522
|
total,
|
|
4470
4523
|
limit: effectiveLimit,
|
|
4471
|
-
offset:
|
|
4524
|
+
offset: requestedOffset,
|
|
4472
4525
|
emails: projectFields(merged, parseFieldsArg(args.fields)),
|
|
4473
4526
|
returned: merged.length,
|
|
4474
|
-
hasMore
|
|
4527
|
+
hasMore,
|
|
4475
4528
|
byAccount: perAccount.map(({ bundle, result }) => ({ slug: bundle.account.slug, total: result.total })),
|
|
4476
4529
|
}, false, merged.map(emailSource));
|
|
4477
4530
|
}
|
|
@@ -5222,10 +5275,15 @@ export function createServer(config, options = {}) {
|
|
|
5222
5275
|
if (wakeAtTime <= Date.now()) {
|
|
5223
5276
|
throw new McpError(ErrorCode.InvalidParams, "wakeAt must be in the future.");
|
|
5224
5277
|
}
|
|
5225
|
-
|
|
5278
|
+
// emailId carries the same "<slug>::" prefix as everywhere else — resolve it
|
|
5279
|
+
// to that account's own snoozeService instead of always the primary's, and
|
|
5280
|
+
// prefix the returned ids (the snooze's own id, and the email id) the same
|
|
5281
|
+
// way so cancel_snooze/list_snoozed can find them again by account.
|
|
5282
|
+
const { bundle: snoozeBundle, rest: snoozeEmailIdRest } = resolveAccountForSnoozeId(requireString(args, "emailId"));
|
|
5283
|
+
const snoozed = await withAudit(auditService, name, args, async () => snoozeBundle.snoozeService.snooze(snoozeEmailIdRest, new Date(wakeAtTime).toISOString()));
|
|
5226
5284
|
return createTextResult({
|
|
5227
|
-
id: snoozed.id,
|
|
5228
|
-
emailId: snoozed.currentEmailId,
|
|
5285
|
+
id: withAccountPrefix(responseSlug(snoozeBundle), snoozed.id),
|
|
5286
|
+
emailId: withAccountPrefix(responseSlug(snoozeBundle), snoozed.currentEmailId),
|
|
5229
5287
|
wakeAt: snoozed.wakeAt,
|
|
5230
5288
|
note: "This server must stay running for the wake to fire at wakeAt — if restarted first, it wakes on next startup instead. Cancelable via cancel_snooze.",
|
|
5231
5289
|
});
|
|
@@ -5234,13 +5292,34 @@ export function createServer(config, options = {}) {
|
|
|
5234
5292
|
// cancel moves the mailbox back to its original folder, same as
|
|
5235
5293
|
// snooze_email's own move — gate it the same way.
|
|
5236
5294
|
ensureEmailActionAllowed(config.runtime, "archive");
|
|
5237
|
-
const
|
|
5238
|
-
|
|
5295
|
+
const { bundle: cancelSnoozeBundle, rest: cancelSnoozeIdRest } = resolveAccountForSnoozeId(requireString(args, "id"));
|
|
5296
|
+
const result = await withAudit(auditService, name, args, async () => cancelSnoozeBundle.snoozeService.cancel(cancelSnoozeIdRest));
|
|
5297
|
+
return createTextResult({
|
|
5298
|
+
...result,
|
|
5299
|
+
id: withAccountPrefix(responseSlug(cancelSnoozeBundle), result.id),
|
|
5300
|
+
currentEmailId: withAccountPrefix(responseSlug(cancelSnoozeBundle), result.currentEmailId),
|
|
5301
|
+
});
|
|
5239
5302
|
}
|
|
5240
5303
|
case "list_snoozed": {
|
|
5241
5304
|
const statusFilter = optionalString(args, "status");
|
|
5242
|
-
|
|
5243
|
-
|
|
5305
|
+
// Same fan-out pattern as list_scheduled_sends — each account has its own
|
|
5306
|
+
// independent snoozeService/JSON file. Single-account guard keeps
|
|
5307
|
+
// byte-identical output for the common case.
|
|
5308
|
+
if (accountManager.all().length === 1) {
|
|
5309
|
+
const all = await withAudit(auditService, name, args, async () => snoozeService.list());
|
|
5310
|
+
const filtered = statusFilter ? all.filter((item) => item.status === statusFilter) : all;
|
|
5311
|
+
return createTextResult(filtered);
|
|
5312
|
+
}
|
|
5313
|
+
const perAccount = await withAudit(auditService, name, args, async () => Promise.all(accountManager.all().map(async (bundle) => ({
|
|
5314
|
+
bundle,
|
|
5315
|
+
items: await bundle.snoozeService.list(),
|
|
5316
|
+
}))));
|
|
5317
|
+
const tagged = perAccount.flatMap(({ bundle, items }) => items.map((item) => ({
|
|
5318
|
+
...item,
|
|
5319
|
+
id: withAccountPrefix(responseSlug(bundle), item.id),
|
|
5320
|
+
currentEmailId: withAccountPrefix(responseSlug(bundle), item.currentEmailId),
|
|
5321
|
+
})));
|
|
5322
|
+
const filtered = statusFilter ? tagged.filter((item) => item.status === statusFilter) : tagged;
|
|
5244
5323
|
return createTextResult(filtered);
|
|
5245
5324
|
}
|
|
5246
5325
|
case "create_template": {
|
|
@@ -6222,24 +6301,32 @@ export function createServer(config, options = {}) {
|
|
|
6222
6301
|
}
|
|
6223
6302
|
case "create_thread_reply_draft":
|
|
6224
6303
|
{
|
|
6225
|
-
|
|
6304
|
+
// A threadId is only ever valid within the single account it was produced
|
|
6305
|
+
// by — same "<slug>::" resolution as get_thread_by_id/move_thread. This was
|
|
6306
|
+
// previously always reading via the PRIMARY account's localIndexService/
|
|
6307
|
+
// imapService regardless of the threadId's own prefix, so a reply drafted
|
|
6308
|
+
// against a non-primary account's thread either resolved the wrong thread
|
|
6309
|
+
// (if the primary happened to have a same-shaped local id) or threw not
|
|
6310
|
+
// found. Every read below now goes through the resolved threadBundle.
|
|
6311
|
+
const { bundle: threadReplyBundle, rest: threadReplyIdRest } = resolveAccountForEmailId(requireString(args, "threadId"));
|
|
6312
|
+
await maybeRefreshLocalIndex(threadReplyBundle.imapService, threadReplyBundle.localIndexService, {
|
|
6226
6313
|
force: normalizeBoolean(args.syncBefore, false),
|
|
6227
6314
|
folder: "INBOX",
|
|
6228
6315
|
limitPerFolder: 100,
|
|
6229
6316
|
});
|
|
6230
|
-
const thread = await localIndexService.getThreadById(
|
|
6231
|
-
const targetMessage = pickReplyTargetFromThread(thread, config.smtp.username, normalizeBoolean(args.preferLatestInbound, true));
|
|
6317
|
+
const thread = await threadReplyBundle.localIndexService.getThreadById(threadReplyIdRest);
|
|
6318
|
+
const targetMessage = pickReplyTargetFromThread(thread, threadReplyBundle.config.smtp.username, normalizeBoolean(args.preferLatestInbound, true));
|
|
6232
6319
|
if (!targetMessage?.primaryEmailId) {
|
|
6233
6320
|
throw new McpError(ErrorCode.InvalidParams, "Unable to resolve a reply target from the thread.");
|
|
6234
6321
|
}
|
|
6235
|
-
const detail = await imapService.getEmailById(targetMessage.primaryEmailId);
|
|
6322
|
+
const detail = await threadReplyBundle.imapService.getEmailById(targetMessage.primaryEmailId);
|
|
6236
6323
|
const body = requireString(args, "body");
|
|
6237
6324
|
const isHtml = normalizeBoolean(args.isHtml, false);
|
|
6238
6325
|
const replyAll = normalizeBoolean(args.replyAll, false);
|
|
6239
6326
|
const attachments = optionalAttachmentList(args.attachments);
|
|
6240
6327
|
const extraCc = parseEmails(optionalString(args, "cc"));
|
|
6241
6328
|
const extraBcc = parseEmails(optionalString(args, "bcc"));
|
|
6242
|
-
const recipients = getReplyRecipients(detail, config.smtp.username, replyAll);
|
|
6329
|
+
const recipients = getReplyRecipients(detail, threadReplyBundle.config.smtp.username, replyAll);
|
|
6243
6330
|
const cc = uniqueAddresses([...recipients.cc, ...extraCc]);
|
|
6244
6331
|
const to = uniqueAddresses(recipients.to);
|
|
6245
6332
|
ensureValidEmails(to, "to");
|