proton-mail-bridge-client 2.1.1 → 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 +139 -45
- 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
|
@@ -420,7 +420,7 @@ const TOOLS = [
|
|
|
420
420
|
},
|
|
421
421
|
{
|
|
422
422
|
name: "update_draft",
|
|
423
|
-
description: "Update an existing locally saved draft's recipients, subject, body, or other fields. Use to edit a draft before sending. Only provided fields are updated — omitted fields retain their current values. After updating, call send_draft to send or sync_draft_to_remote to push to Proton Drafts.",
|
|
423
|
+
description: "Update an existing locally saved draft's recipients, subject, body, or other fields. Use to edit a draft before sending. Only provided fields are updated — omitted fields retain their current values. After updating, call send_draft to send or sync_draft_to_remote to push to Proton Drafts. The response's attachments are metadata only (filename/contentType/size, no base64 content) even if the draft has attachments from a previous call — use get_draft if you need the actual attachment bytes back.",
|
|
424
424
|
annotations: { destructiveHint: false },
|
|
425
425
|
inputSchema: {
|
|
426
426
|
type: "object",
|
|
@@ -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": {
|
|
@@ -4119,7 +4162,14 @@ export function createServer(config, options = {}) {
|
|
|
4119
4162
|
: undefined);
|
|
4120
4163
|
return presentDraft(updateDraftBundle, remoteSync ? { ...synced.draft, remoteSync } : synced.draft);
|
|
4121
4164
|
});
|
|
4122
|
-
|
|
4165
|
+
// update_draft is typically called repeatedly against the same draft to tweak
|
|
4166
|
+
// subject/body — the attachments usually aren't touched by this call at all, so
|
|
4167
|
+
// re-echoing their full base64 `content` (serialized twice by createTextResult,
|
|
4168
|
+
// same issue redactDraftAttachmentsForListing already fixed for list_drafts)
|
|
4169
|
+
// burns tokens on every single edit for a draft that has any attachment. The
|
|
4170
|
+
// caller already knows what it attached; get_draft remains full-content for the
|
|
4171
|
+
// "let me look at this draft" case, which is a single call rather than a loop.
|
|
4172
|
+
return createTextResult(redactDraftAttachmentsForListing(result), false, [
|
|
4123
4173
|
draftSource(result),
|
|
4124
4174
|
...(result.remoteDraft?.emailId
|
|
4125
4175
|
? [
|
|
@@ -4170,7 +4220,7 @@ export function createServer(config, options = {}) {
|
|
|
4170
4220
|
// immediate send is actually what's wanted.
|
|
4171
4221
|
const pendingScheduled = (await sendDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
|
|
4172
4222
|
if (pendingScheduled) {
|
|
4173
|
-
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.`);
|
|
4174
4224
|
}
|
|
4175
4225
|
ensureValidEmails(draft.to, "to");
|
|
4176
4226
|
ensureValidEmails(draft.cc, "cc");
|
|
@@ -4351,7 +4401,7 @@ export function createServer(config, options = {}) {
|
|
|
4351
4401
|
// in the common non-racing case.
|
|
4352
4402
|
const alreadyPendingScheduled = (await scheduleDraftBundle.deliveryQueueService.list()).find((record) => record.sourceDraftId === draft.id && record.status === "pending");
|
|
4353
4403
|
if (alreadyPendingScheduled) {
|
|
4354
|
-
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.`);
|
|
4355
4405
|
}
|
|
4356
4406
|
const sendAt = requireString(args, "sendAt");
|
|
4357
4407
|
const sendAtTime = new Date(sendAt).getTime();
|
|
@@ -4394,7 +4444,7 @@ export function createServer(config, options = {}) {
|
|
|
4394
4444
|
}, new Date(sendAtTime).toISOString(), "scheduled_send", draft.id));
|
|
4395
4445
|
return createTextResult({
|
|
4396
4446
|
queued: true,
|
|
4397
|
-
id: queued.id,
|
|
4447
|
+
id: withAccountPrefix(responseSlug(scheduleDraftBundle), queued.id),
|
|
4398
4448
|
draftId: withAccountPrefix(responseSlug(scheduleDraftBundle), draft.id),
|
|
4399
4449
|
sendAt: queued.sendAt,
|
|
4400
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.",
|
|
@@ -4445,26 +4495,36 @@ export function createServer(config, options = {}) {
|
|
|
4445
4495
|
hasMore: result.emails.length === effectiveLimit,
|
|
4446
4496
|
}, false, result.emails.map(emailSource));
|
|
4447
4497
|
}
|
|
4448
|
-
// Merge strategy:
|
|
4449
|
-
//
|
|
4450
|
-
//
|
|
4451
|
-
//
|
|
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.
|
|
4452
4509
|
const primarySlug = accountManager.primary().account.slug;
|
|
4510
|
+
const requestedOffset = getEmailsInput.offset ?? 0;
|
|
4511
|
+
const perAccountFetchLimit = requestedOffset + effectiveLimit;
|
|
4453
4512
|
const perAccount = await Promise.all(accountManager.all().map(async (bundle) => {
|
|
4454
|
-
const result = await bundle.imapService.getEmails(getEmailsInput);
|
|
4513
|
+
const result = await bundle.imapService.getEmails({ ...getEmailsInput, offset: 0, limit: perAccountFetchLimit });
|
|
4455
4514
|
return { bundle, result };
|
|
4456
4515
|
}));
|
|
4457
4516
|
const taggedEmails = perAccount.flatMap(({ bundle, result }) => result.emails.map((email) => ({ ...email, id: withAccountPrefix(accountSlugForTag(bundle, primarySlug), email.id) })));
|
|
4458
|
-
const
|
|
4517
|
+
const mergedAll = sortByDateDesc(taggedEmails, (email) => email.internalDate || email.date);
|
|
4518
|
+
const { page: merged, hasMore } = paginateMergedAccountResults(mergedAll, requestedOffset, effectiveLimit);
|
|
4459
4519
|
const total = perAccount.reduce((sum, { result }) => sum + result.total, 0);
|
|
4460
4520
|
return createTextResult({
|
|
4461
4521
|
folder: getEmailsInput.folder?.trim() || "INBOX",
|
|
4462
4522
|
total,
|
|
4463
4523
|
limit: effectiveLimit,
|
|
4464
|
-
offset:
|
|
4524
|
+
offset: requestedOffset,
|
|
4465
4525
|
emails: projectFields(merged, parseFieldsArg(args.fields)),
|
|
4466
4526
|
returned: merged.length,
|
|
4467
|
-
hasMore
|
|
4527
|
+
hasMore,
|
|
4468
4528
|
byAccount: perAccount.map(({ bundle, result }) => ({ slug: bundle.account.slug, total: result.total })),
|
|
4469
4529
|
}, false, merged.map(emailSource));
|
|
4470
4530
|
}
|
|
@@ -5215,10 +5275,15 @@ export function createServer(config, options = {}) {
|
|
|
5215
5275
|
if (wakeAtTime <= Date.now()) {
|
|
5216
5276
|
throw new McpError(ErrorCode.InvalidParams, "wakeAt must be in the future.");
|
|
5217
5277
|
}
|
|
5218
|
-
|
|
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()));
|
|
5219
5284
|
return createTextResult({
|
|
5220
|
-
id: snoozed.id,
|
|
5221
|
-
emailId: snoozed.currentEmailId,
|
|
5285
|
+
id: withAccountPrefix(responseSlug(snoozeBundle), snoozed.id),
|
|
5286
|
+
emailId: withAccountPrefix(responseSlug(snoozeBundle), snoozed.currentEmailId),
|
|
5222
5287
|
wakeAt: snoozed.wakeAt,
|
|
5223
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.",
|
|
5224
5289
|
});
|
|
@@ -5227,13 +5292,34 @@ export function createServer(config, options = {}) {
|
|
|
5227
5292
|
// cancel moves the mailbox back to its original folder, same as
|
|
5228
5293
|
// snooze_email's own move — gate it the same way.
|
|
5229
5294
|
ensureEmailActionAllowed(config.runtime, "archive");
|
|
5230
|
-
const
|
|
5231
|
-
|
|
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
|
+
});
|
|
5232
5302
|
}
|
|
5233
5303
|
case "list_snoozed": {
|
|
5234
5304
|
const statusFilter = optionalString(args, "status");
|
|
5235
|
-
|
|
5236
|
-
|
|
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;
|
|
5237
5323
|
return createTextResult(filtered);
|
|
5238
5324
|
}
|
|
5239
5325
|
case "create_template": {
|
|
@@ -6215,24 +6301,32 @@ export function createServer(config, options = {}) {
|
|
|
6215
6301
|
}
|
|
6216
6302
|
case "create_thread_reply_draft":
|
|
6217
6303
|
{
|
|
6218
|
-
|
|
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, {
|
|
6219
6313
|
force: normalizeBoolean(args.syncBefore, false),
|
|
6220
6314
|
folder: "INBOX",
|
|
6221
6315
|
limitPerFolder: 100,
|
|
6222
6316
|
});
|
|
6223
|
-
const thread = await localIndexService.getThreadById(
|
|
6224
|
-
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));
|
|
6225
6319
|
if (!targetMessage?.primaryEmailId) {
|
|
6226
6320
|
throw new McpError(ErrorCode.InvalidParams, "Unable to resolve a reply target from the thread.");
|
|
6227
6321
|
}
|
|
6228
|
-
const detail = await imapService.getEmailById(targetMessage.primaryEmailId);
|
|
6322
|
+
const detail = await threadReplyBundle.imapService.getEmailById(targetMessage.primaryEmailId);
|
|
6229
6323
|
const body = requireString(args, "body");
|
|
6230
6324
|
const isHtml = normalizeBoolean(args.isHtml, false);
|
|
6231
6325
|
const replyAll = normalizeBoolean(args.replyAll, false);
|
|
6232
6326
|
const attachments = optionalAttachmentList(args.attachments);
|
|
6233
6327
|
const extraCc = parseEmails(optionalString(args, "cc"));
|
|
6234
6328
|
const extraBcc = parseEmails(optionalString(args, "bcc"));
|
|
6235
|
-
const recipients = getReplyRecipients(detail, config.smtp.username, replyAll);
|
|
6329
|
+
const recipients = getReplyRecipients(detail, threadReplyBundle.config.smtp.username, replyAll);
|
|
6236
6330
|
const cc = uniqueAddresses([...recipients.cc, ...extraCc]);
|
|
6237
6331
|
const to = uniqueAddresses(recipients.to);
|
|
6238
6332
|
ensureValidEmails(to, "to");
|