proton-mail-bridge-client 2.0.1 → 2.0.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/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -2
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +228 -73
- package/dist/index.js.map +1 -1
- package/dist/services/audit-service.d.ts +2 -0
- package/dist/services/audit-service.d.ts.map +1 -1
- package/dist/services/audit-service.js +16 -0
- package/dist/services/audit-service.js.map +1 -1
- package/dist/services/delivery-queue-service.d.ts +4 -0
- package/dist/services/delivery-queue-service.d.ts.map +1 -1
- package/dist/services/delivery-queue-service.js +82 -0
- package/dist/services/delivery-queue-service.js.map +1 -1
- package/dist/services/draft-store-service.d.ts +3 -0
- package/dist/services/draft-store-service.d.ts.map +1 -1
- package/dist/services/draft-store-service.js +64 -1
- package/dist/services/draft-store-service.js.map +1 -1
- package/dist/services/local-index-service.d.ts +6 -0
- package/dist/services/local-index-service.d.ts.map +1 -1
- package/dist/services/local-index-service.js +181 -11
- package/dist/services/local-index-service.js.map +1 -1
- package/dist/services/simple-imap-service.d.ts +11 -5
- package/dist/services/simple-imap-service.d.ts.map +1 -1
- package/dist/services/simple-imap-service.js +337 -69
- package/dist/services/simple-imap-service.js.map +1 -1
- package/dist/services/snooze-service.d.ts +1 -0
- package/dist/services/snooze-service.d.ts.map +1 -1
- package/dist/services/snooze-service.js +8 -0
- package/dist/services/snooze-service.js.map +1 -1
- package/dist/services/template-service.d.ts +1 -0
- package/dist/services/template-service.d.ts.map +1 -1
- package/dist/services/template-service.js +8 -0
- package/dist/services/template-service.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/account-identity.d.ts +8 -0
- package/dist/utils/account-identity.d.ts.map +1 -0
- package/dist/utils/account-identity.js +105 -0
- package/dist/utils/account-identity.js.map +1 -0
- package/dist/utils/file-lock.d.ts.map +1 -1
- package/dist/utils/file-lock.js +15 -3
- package/dist/utils/file-lock.js.map +1 -1
- package/dist/utils/helpers.d.ts +2 -1
- package/dist/utils/helpers.d.ts.map +1 -1
- package/dist/utils/helpers.js +65 -9
- package/dist/utils/helpers.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { BackgroundSyncService } from "./services/background-sync-service.js";
|
|
|
16
16
|
import { DeliveryQueueService } from "./services/delivery-queue-service.js";
|
|
17
17
|
import { DraftStoreService } from "./services/draft-store-service.js";
|
|
18
18
|
import { LocalIndexService } from "./services/local-index-service.js";
|
|
19
|
-
import { BULK_ITEM_TIMEOUT_MS, describeImapError, isLikelyAuthenticationError, isLikelyConnectionError, isLikelyTlsMismatchError, SimpleIMAPService } from "./services/simple-imap-service.js";
|
|
19
|
+
import { BULK_ITEM_TIMEOUT_MS, describeImapError, isLikelyAuthenticationError, isLikelyConnectionError, isLikelyTlsMismatchError, SimpleIMAPService, UID_VALIDITY_MISMATCH_ERROR } from "./services/simple-imap-service.js";
|
|
20
20
|
import { applySignature, SMTPService } from "./services/smtp-service.js";
|
|
21
21
|
import { SnoozeService } from "./services/snooze-service.js";
|
|
22
22
|
import { TemplateService } from "./services/template-service.js";
|
|
@@ -135,13 +135,14 @@ const TOOLS = [
|
|
|
135
135
|
},
|
|
136
136
|
{
|
|
137
137
|
name: "send_test_email",
|
|
138
|
-
description: "Send a minimal diagnostic email to confirm Proton Bridge SMTP credentials and connectivity. Use before relying on send_email in a new environment. Prefer get_connection_status for a connectivity check that does not actually send mail. Returns transport debug info and delivery status.",
|
|
138
|
+
description: "Send a minimal diagnostic email to confirm Proton Bridge SMTP credentials and connectivity. Use before relying on send_email in a new environment. Prefer get_connection_status for a connectivity check that does not actually send mail. Returns transport debug info and delivery status. Requires PROTONMAIL_ALLOW_SEND, and confirmed:true when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled; subject to PROTONMAIL_RESTRICT_OUTBOUND_TO_SELF like every other send path.",
|
|
139
139
|
annotations: { destructiveHint: true },
|
|
140
140
|
inputSchema: {
|
|
141
141
|
type: "object",
|
|
142
142
|
properties: {
|
|
143
143
|
to: { type: "string", description: "Recipient email address." },
|
|
144
144
|
customMessage: { type: "string", description: "Optional custom test body." },
|
|
145
|
+
confirmed: { type: "boolean", description: "Set to true to confirm this send when PROTONMAIL_CONFIRM_DESTRUCTIVE is enabled." },
|
|
145
146
|
},
|
|
146
147
|
required: ["to"],
|
|
147
148
|
},
|
|
@@ -2369,27 +2370,32 @@ async function getAnalyticsSampleFromIndex(imapService, localIndexService, days,
|
|
|
2369
2370
|
return result.emails;
|
|
2370
2371
|
}
|
|
2371
2372
|
async function runEmailAction(imapService, emailId, action, targetFolder) {
|
|
2373
|
+
// undefined for an old-format id (no embedded UIDVALIDITY) — every method
|
|
2374
|
+
// below's own assertMailboxUidValidity guard already treats that as
|
|
2375
|
+
// "unverifiable, don't block on it", same as every direct single-message
|
|
2376
|
+
// tool handler.
|
|
2377
|
+
const uidValidity = parseEmailId(emailId).uidValidity;
|
|
2372
2378
|
switch (action) {
|
|
2373
2379
|
case "mark_read":
|
|
2374
|
-
return imapService.markEmailRead(emailId, true);
|
|
2380
|
+
return imapService.markEmailRead(emailId, true, uidValidity);
|
|
2375
2381
|
case "mark_unread":
|
|
2376
|
-
return imapService.markEmailRead(emailId, false);
|
|
2382
|
+
return imapService.markEmailRead(emailId, false, uidValidity);
|
|
2377
2383
|
case "star":
|
|
2378
|
-
return imapService.starEmail(emailId, true);
|
|
2384
|
+
return imapService.starEmail(emailId, true, uidValidity);
|
|
2379
2385
|
case "unstar":
|
|
2380
|
-
return imapService.starEmail(emailId, false);
|
|
2386
|
+
return imapService.starEmail(emailId, false, uidValidity);
|
|
2381
2387
|
case "archive":
|
|
2382
|
-
return imapService.archiveEmail(emailId);
|
|
2388
|
+
return imapService.archiveEmail(emailId, uidValidity);
|
|
2383
2389
|
case "trash":
|
|
2384
|
-
return imapService.trashEmail(emailId);
|
|
2390
|
+
return imapService.trashEmail(emailId, uidValidity);
|
|
2385
2391
|
case "restore":
|
|
2386
|
-
return imapService.restoreEmail(emailId, targetFolder);
|
|
2392
|
+
return imapService.restoreEmail(emailId, targetFolder, uidValidity);
|
|
2387
2393
|
case "move":
|
|
2388
2394
|
if (!targetFolder)
|
|
2389
2395
|
throw new McpError(ErrorCode.InvalidParams, "targetFolder is required for action 'move'.");
|
|
2390
|
-
return imapService.moveEmail(emailId, targetFolder);
|
|
2396
|
+
return imapService.moveEmail(emailId, targetFolder, uidValidity);
|
|
2391
2397
|
case "delete":
|
|
2392
|
-
return imapService.deleteEmail(emailId);
|
|
2398
|
+
return imapService.deleteEmail(emailId, uidValidity);
|
|
2393
2399
|
}
|
|
2394
2400
|
}
|
|
2395
2401
|
function emailSourceFromActionResult(result) {
|
|
@@ -2500,11 +2506,19 @@ function ensureBulkBatchSize(uidsLength, max) {
|
|
|
2500
2506
|
throw new McpError(ErrorCode.InvalidParams, "Batch size " + uidsLength + " exceeds limit " + max + ". Use match criteria to narrow the scope, or increase maxBatchSize.");
|
|
2501
2507
|
}
|
|
2502
2508
|
}
|
|
2503
|
-
|
|
2509
|
+
// `currentUidValidity` is optional and, when omitted, preserves the exact
|
|
2510
|
+
// original behavior (folder/format check only) — every caller that already
|
|
2511
|
+
// knows the folder's current UIDVALIDITY (the bulk_delete/bulk_update_flags/
|
|
2512
|
+
// bulk_update_labels handlers below, via a single shared getMailboxUidValidity
|
|
2513
|
+
// call reused for both this and resolveUidsForBulkOp) should pass it, so a
|
|
2514
|
+
// stale-generation id gets its own clear error instead of being lumped in
|
|
2515
|
+
// with a genuinely malformed one.
|
|
2516
|
+
function getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity) {
|
|
2504
2517
|
if (!emailIds) {
|
|
2505
2518
|
return [];
|
|
2506
2519
|
}
|
|
2507
|
-
|
|
2520
|
+
const excluded = [];
|
|
2521
|
+
for (const emailId of emailIds) {
|
|
2508
2522
|
// Found live: this compared the emailId's folder segment against
|
|
2509
2523
|
// `folder` as raw, still-percent-encoded text — createEmailId encodes
|
|
2510
2524
|
// it (Folders/MCP-Snoozed -> Folders%2FMCP-Snoozed) but `folder` here
|
|
@@ -2515,30 +2529,50 @@ function getBulkNotFoundEmailIds(emailIds, folder) {
|
|
|
2515
2529
|
// reported genuinely valid ids as notFound. parseEmailId already does
|
|
2516
2530
|
// the matching decodeURIComponent correctly — reuse it instead of
|
|
2517
2531
|
// hand-rolling the same parse a second, inconsistent way.
|
|
2532
|
+
let parsed;
|
|
2518
2533
|
try {
|
|
2519
|
-
|
|
2520
|
-
return parsed.folder !== folder;
|
|
2534
|
+
parsed = parseEmailId(emailId);
|
|
2521
2535
|
}
|
|
2522
2536
|
catch {
|
|
2523
|
-
|
|
2537
|
+
excluded.push({ emailId, error: "Email ID could not be resolved to a UID." });
|
|
2538
|
+
continue;
|
|
2524
2539
|
}
|
|
2525
|
-
|
|
2540
|
+
if (parsed.folder !== folder) {
|
|
2541
|
+
excluded.push({ emailId, error: "Email ID could not be resolved to a UID." });
|
|
2542
|
+
continue;
|
|
2543
|
+
}
|
|
2544
|
+
// An id with no embedded uidValidity (pre-this-fix format) is
|
|
2545
|
+
// unverifiable, not stale — same "can't check it, so don't block on
|
|
2546
|
+
// it" stance assertMailboxUidValidity already takes for the
|
|
2547
|
+
// single-message mutation path. Only a *checkable* mismatch is
|
|
2548
|
+
// treated as a stale reference. resolveUidsForBulkOp applies this
|
|
2549
|
+
// exact same exclusion (see its own comment) against the exact same
|
|
2550
|
+
// currentUidValidity value, so the uid this method silently drops from
|
|
2551
|
+
// execution and the id this method reports as excluded here always
|
|
2552
|
+
// agree.
|
|
2553
|
+
if (currentUidValidity !== undefined &&
|
|
2554
|
+
parsed.uidValidity !== undefined &&
|
|
2555
|
+
parsed.uidValidity !== currentUidValidity) {
|
|
2556
|
+
excluded.push({ emailId, error: UID_VALIDITY_MISMATCH_ERROR });
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
return excluded;
|
|
2526
2560
|
}
|
|
2527
|
-
function withBulkNotFound(result,
|
|
2528
|
-
if (
|
|
2561
|
+
function withBulkNotFound(result, excludedEmailIds) {
|
|
2562
|
+
if (excludedEmailIds.length === 0) {
|
|
2529
2563
|
return result;
|
|
2530
2564
|
}
|
|
2531
2565
|
return {
|
|
2532
2566
|
...result,
|
|
2533
|
-
total: result.total +
|
|
2534
|
-
notFound: result.notFound +
|
|
2567
|
+
total: result.total + excludedEmailIds.length,
|
|
2568
|
+
notFound: result.notFound + excludedEmailIds.length,
|
|
2535
2569
|
results: [
|
|
2536
2570
|
...result.results,
|
|
2537
|
-
...
|
|
2571
|
+
...excludedEmailIds.map(({ emailId, error }) => ({
|
|
2538
2572
|
uid: 0,
|
|
2539
2573
|
emailId,
|
|
2540
2574
|
ok: false,
|
|
2541
|
-
error
|
|
2575
|
+
error,
|
|
2542
2576
|
})),
|
|
2543
2577
|
],
|
|
2544
2578
|
};
|
|
@@ -2776,6 +2810,9 @@ export function createServer(config, options = {}) {
|
|
|
2776
2810
|
const draftStore = new DraftStoreService(config, logger);
|
|
2777
2811
|
const backgroundSyncService = new BackgroundSyncService(config, imapService, localIndexService, logger);
|
|
2778
2812
|
const deliveryQueueService = new DeliveryQueueService(config, smtpService, logger);
|
|
2813
|
+
// Lets a fired scheduled_send close the loop back to its source draft —
|
|
2814
|
+
// see DeliveryQueueService.checkDue()'s draftStore usage.
|
|
2815
|
+
deliveryQueueService.setDraftStore(draftStore);
|
|
2779
2816
|
const snoozeService = new SnoozeService(config, imapService, logger);
|
|
2780
2817
|
const templateService = new TemplateService(config, logger);
|
|
2781
2818
|
if (options.startBackgroundSync) {
|
|
@@ -3075,11 +3112,13 @@ export function createServer(config, options = {}) {
|
|
|
3075
3112
|
});
|
|
3076
3113
|
}
|
|
3077
3114
|
case "send_test_email": {
|
|
3078
|
-
ensureSendAllowed(config.runtime);
|
|
3079
3115
|
const to = requireString(args, "to");
|
|
3080
3116
|
if (!isValidEmail(to)) {
|
|
3081
3117
|
throw new McpError(ErrorCode.InvalidParams, "to must be a valid email address.");
|
|
3082
3118
|
}
|
|
3119
|
+
ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Send test email to ${to}`);
|
|
3120
|
+
ensureSendAllowed(config.runtime);
|
|
3121
|
+
ensureOutboundRecipientsAllowed(config.runtime, config.smtp.username, [to]);
|
|
3083
3122
|
const result = await withAudit(auditService, name, args, async () => smtpService.sendTestEmail(to, optionalString(args, "customMessage")));
|
|
3084
3123
|
return createTextResult({
|
|
3085
3124
|
messageId: result.messageId,
|
|
@@ -3676,24 +3715,78 @@ export function createServer(config, options = {}) {
|
|
|
3676
3715
|
note: "No email was sent.",
|
|
3677
3716
|
}, false, [draftSource(draft)]);
|
|
3678
3717
|
}
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3718
|
+
// Atomic claim: the status/pending-scheduled checks above are read
|
|
3719
|
+
// then acted on non-atomically, so two concurrent send_draft calls
|
|
3720
|
+
// (or a scheduled send firing at the same instant) could both pass
|
|
3721
|
+
// them. claimForSending() re-reads status under the draft store's
|
|
3722
|
+
// lock and flips "draft" -> "sending" in that same critical
|
|
3723
|
+
// section — only the call that wins the claim proceeds to SMTP.
|
|
3724
|
+
await draftStore.claimForSending(draft.id);
|
|
3725
|
+
// Found live: withAudit wrapped the SMTP call, so an audit-log
|
|
3726
|
+
// write failure AFTER a successful send (e.g. disk full) was
|
|
3727
|
+
// indistinguishable from a send failure — it threw out of
|
|
3728
|
+
// withAudit, and the catch below reverted an already-sent draft
|
|
3729
|
+
// back to "draft", making it resendable and enabling a genuine
|
|
3730
|
+
// duplicate delivery. SMTP is called directly here so its outcome
|
|
3731
|
+
// (and the revert-on-failure below) can't be corrupted by a
|
|
3732
|
+
// separate audit-write failure — the audit record for the success
|
|
3733
|
+
// case is written afterward, on its own, with a failure there only
|
|
3734
|
+
// logged, never reverting a send that already happened.
|
|
3735
|
+
const sendStartedAt = Date.now();
|
|
3736
|
+
let result;
|
|
3737
|
+
try {
|
|
3738
|
+
result = await smtpService.sendEmail({
|
|
3739
|
+
to: draft.to,
|
|
3740
|
+
cc: draft.cc,
|
|
3741
|
+
bcc: draft.bcc,
|
|
3742
|
+
subject: draft.subject,
|
|
3743
|
+
body: draft.body,
|
|
3744
|
+
isHtml: draft.isHtml,
|
|
3745
|
+
priority: draft.priority,
|
|
3746
|
+
replyTo: draft.replyTo,
|
|
3747
|
+
inReplyTo: draft.inReplyTo,
|
|
3748
|
+
references: draft.references,
|
|
3749
|
+
attachments: draft.attachments,
|
|
3750
|
+
// Draft content is already finalized and reviewed — appending a
|
|
3751
|
+
// signature invisibly at send time would change what the user
|
|
3752
|
+
// saw and approved. If a signature is wanted, it belongs in the
|
|
3753
|
+
// draft body itself.
|
|
3754
|
+
appendSignature: false,
|
|
3755
|
+
});
|
|
3756
|
+
}
|
|
3757
|
+
catch (error) {
|
|
3758
|
+
// Mirrors SnoozeService.wake()'s catch handler: revert the claim
|
|
3759
|
+
// so the draft is retryable instead of stuck in "sending"
|
|
3760
|
+
// forever because SMTP failed.
|
|
3761
|
+
await draftStore.revertSending(draft.id);
|
|
3762
|
+
await auditService
|
|
3763
|
+
.record({
|
|
3764
|
+
timestamp: new Date().toISOString(),
|
|
3765
|
+
tool: name,
|
|
3766
|
+
status: "error",
|
|
3767
|
+
durationMs: Date.now() - sendStartedAt,
|
|
3768
|
+
input: sanitizeAuditValue(args),
|
|
3769
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3770
|
+
})
|
|
3771
|
+
.catch((auditError) => logger.error("Failed to record audit log for a failed send_draft", "MCPServer", {
|
|
3772
|
+
draftId: draft.id,
|
|
3773
|
+
error: auditError,
|
|
3774
|
+
}));
|
|
3775
|
+
throw error;
|
|
3776
|
+
}
|
|
3777
|
+
try {
|
|
3778
|
+
await auditService.record({
|
|
3779
|
+
timestamp: new Date().toISOString(),
|
|
3780
|
+
tool: name,
|
|
3781
|
+
status: "success",
|
|
3782
|
+
durationMs: Date.now() - sendStartedAt,
|
|
3783
|
+
input: sanitizeAuditValue(args),
|
|
3784
|
+
result: sanitizeAuditValue(result),
|
|
3785
|
+
});
|
|
3786
|
+
}
|
|
3787
|
+
catch (auditError) {
|
|
3788
|
+
logger.error("Failed to record audit log for a successful send_draft — the email was already sent, so the draft is not being reverted", "MCPServer", { draftId: draft.id, error: auditError });
|
|
3789
|
+
}
|
|
3697
3790
|
let sentDraft = await draftStore.markSent(draft.id, {
|
|
3698
3791
|
messageId: result.messageId,
|
|
3699
3792
|
accepted: result.accepted,
|
|
@@ -3938,7 +4031,12 @@ export function createServer(config, options = {}) {
|
|
|
3938
4031
|
if (labelsToAdd.length === 0 && labelsToRemove.length === 0) {
|
|
3939
4032
|
throw new McpError(ErrorCode.InvalidParams, "Provide at least one label in labelsToAdd or labelsToRemove.");
|
|
3940
4033
|
}
|
|
3941
|
-
|
|
4034
|
+
// undefined for an old-format id (no embedded UIDVALIDITY) —
|
|
4035
|
+
// updateMessageLabels's own assertMailboxUidValidity guard already
|
|
4036
|
+
// treats that as "unverifiable, don't block on it", same as every
|
|
4037
|
+
// other mutation handler below.
|
|
4038
|
+
const uidValidity = parseEmailId(emailId).uidValidity;
|
|
4039
|
+
const result = await withAudit(auditService, name, args, async () => imapService.updateMessageLabels(emailId, labelsToAdd, labelsToRemove, uidValidity));
|
|
3942
4040
|
return createTextResult(result, false, [emailSource({ id: emailId })]);
|
|
3943
4041
|
}
|
|
3944
4042
|
case "update_message_flags": {
|
|
@@ -3953,7 +4051,8 @@ export function createServer(config, options = {}) {
|
|
|
3953
4051
|
if (flagsToAdd.length === 0 && flagsToRemove.length === 0) {
|
|
3954
4052
|
throw new McpError(ErrorCode.InvalidParams, "Provide at least one flag in flagsToAdd or flagsToRemove.");
|
|
3955
4053
|
}
|
|
3956
|
-
const
|
|
4054
|
+
const uidValidity = parseEmailId(emailId).uidValidity;
|
|
4055
|
+
const result = await withAudit(auditService, name, args, async () => imapService.updateMessageFlags(emailId, flagsToAdd, flagsToRemove, uidValidity));
|
|
3957
4056
|
return createTextResult(result, false, [emailSource({ id: emailId })]);
|
|
3958
4057
|
}
|
|
3959
4058
|
case "count_messages": {
|
|
@@ -4016,7 +4115,12 @@ export function createServer(config, options = {}) {
|
|
|
4016
4115
|
const folder = optionalString(args, "folder") ?? "INBOX";
|
|
4017
4116
|
const targetFolder = requireString(args, "targetFolder");
|
|
4018
4117
|
const max = getBulkMaxBatchSize(args);
|
|
4019
|
-
|
|
4118
|
+
// Only fetched (an extra STATUS round trip) when there are ids to
|
|
4119
|
+
// check against it — a match-only bulk_move has no ids that could
|
|
4120
|
+
// be stale, since match resolves directly against the live
|
|
4121
|
+
// mailbox each time.
|
|
4122
|
+
const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
|
|
4123
|
+
const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
|
|
4020
4124
|
const preview = await imapService.bulkMove({
|
|
4021
4125
|
emailIds,
|
|
4022
4126
|
match,
|
|
@@ -4054,16 +4158,32 @@ export function createServer(config, options = {}) {
|
|
|
4054
4158
|
throw new McpError(ErrorCode.InvalidParams, "Provide emailIds OR match, not both.");
|
|
4055
4159
|
const folder = optionalString(args, "folder") ?? "INBOX";
|
|
4056
4160
|
const max = getBulkMaxBatchSize(args);
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4161
|
+
// Fetched once, up front, and reused for both the notFound
|
|
4162
|
+
// reporting below and resolveUidsForBulkOp's own filtering — a
|
|
4163
|
+
// single shared point-in-time value, same "resolve once" spirit
|
|
4164
|
+
// as the uids resolution itself, so the ids this call reports as
|
|
4165
|
+
// excluded and the uids it actually acts on can never disagree
|
|
4166
|
+
// about which generation was current. Only fetched when there are
|
|
4167
|
+
// ids to check against it (a match-only call has none).
|
|
4168
|
+
const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
|
|
4169
|
+
const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
|
|
4170
|
+
// Resolve the match/emailIds set exactly once and reuse it for both
|
|
4171
|
+
// the preview and the real run — see resolveUidsForBulkOp's
|
|
4172
|
+
// comment for why re-resolving `match` a second time (the old
|
|
4173
|
+
// dry-run-then-real-run pattern) could silently exceed maxBatchSize.
|
|
4174
|
+
// Also excludes any id whose embedded UIDVALIDITY is stale — see
|
|
4175
|
+
// resolveUidsForBulkOp's comment.
|
|
4176
|
+
const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
|
|
4177
|
+
ensureBulkBatchSize(uids.length, max);
|
|
4066
4178
|
if (normalizeBoolean(args.dryRun, false)) {
|
|
4179
|
+
const preview = await imapService.bulkDelete({
|
|
4180
|
+
emailIds,
|
|
4181
|
+
match,
|
|
4182
|
+
folder,
|
|
4183
|
+
permanent,
|
|
4184
|
+
resolvedUids: uids,
|
|
4185
|
+
dryRun: true,
|
|
4186
|
+
});
|
|
4067
4187
|
return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
|
|
4068
4188
|
}
|
|
4069
4189
|
const result = await withAudit(auditService, name, args, () => imapService.bulkDelete({
|
|
@@ -4071,6 +4191,7 @@ export function createServer(config, options = {}) {
|
|
|
4071
4191
|
match,
|
|
4072
4192
|
folder,
|
|
4073
4193
|
permanent,
|
|
4194
|
+
resolvedUids: uids,
|
|
4074
4195
|
dryRun: false,
|
|
4075
4196
|
}));
|
|
4076
4197
|
return createTextResult(withBulkNotFound(result, notFoundEmailIds));
|
|
@@ -4093,13 +4214,22 @@ export function createServer(config, options = {}) {
|
|
|
4093
4214
|
throw new McpError(ErrorCode.InvalidParams, "Provide flagsToAdd or flagsToRemove.");
|
|
4094
4215
|
const folder = optionalString(args, "folder") ?? "INBOX";
|
|
4095
4216
|
const max = getBulkMaxBatchSize(args);
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4217
|
+
// See the bulk_delete case above for why this is fetched once and
|
|
4218
|
+
// shared between notFound reporting and uid resolution.
|
|
4219
|
+
const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
|
|
4220
|
+
const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
|
|
4221
|
+
// Resolve the match/emailIds set exactly once and reuse it for both
|
|
4222
|
+
// the preview and the real run — see resolveUidsForBulkOp's
|
|
4223
|
+
// comment for why re-resolving `match` a second time (the old
|
|
4224
|
+
// dry-run-then-real-run pattern) could silently exceed maxBatchSize.
|
|
4225
|
+
// Also excludes any id whose embedded UIDVALIDITY is stale.
|
|
4226
|
+
const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
|
|
4227
|
+
ensureBulkBatchSize(uids.length, max);
|
|
4099
4228
|
if (normalizeBoolean(args.dryRun, false)) {
|
|
4229
|
+
const preview = await imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, dryRun: true });
|
|
4100
4230
|
return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
|
|
4101
4231
|
}
|
|
4102
|
-
const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, dryRun: false }));
|
|
4232
|
+
const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateFlags({ emailIds, match, folder, flagsToAdd, flagsToRemove, resolvedUids: uids, dryRun: false }));
|
|
4103
4233
|
return createTextResult(withBulkNotFound(result, notFoundEmailIds));
|
|
4104
4234
|
}
|
|
4105
4235
|
case "bulk_update_labels": {
|
|
@@ -4118,13 +4248,22 @@ export function createServer(config, options = {}) {
|
|
|
4118
4248
|
const labelsToRemove = Array.isArray(args.labelsToRemove) ? args.labelsToRemove.map(String) : [];
|
|
4119
4249
|
const folder = optionalString(args, "folder") ?? "INBOX";
|
|
4120
4250
|
const max = getBulkMaxBatchSize(args);
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4251
|
+
// See the bulk_delete case above for why this is fetched once and
|
|
4252
|
+
// shared between notFound reporting and uid resolution.
|
|
4253
|
+
const currentUidValidity = emailIds ? await imapService.getMailboxUidValidity(folder) : undefined;
|
|
4254
|
+
const notFoundEmailIds = getBulkNotFoundEmailIds(emailIds, folder, currentUidValidity);
|
|
4255
|
+
// Resolve the match/emailIds set exactly once and reuse it for both
|
|
4256
|
+
// the preview and the real run — see resolveUidsForBulkOp's
|
|
4257
|
+
// comment for why re-resolving `match` a second time (the old
|
|
4258
|
+
// dry-run-then-real-run pattern) could silently exceed maxBatchSize.
|
|
4259
|
+
// Also excludes any id whose embedded UIDVALIDITY is stale.
|
|
4260
|
+
const uids = await imapService.resolveUidsForBulkOp(folder, emailIds, match, currentUidValidity);
|
|
4261
|
+
ensureBulkBatchSize(uids.length, max);
|
|
4124
4262
|
if (normalizeBoolean(args.dryRun, false)) {
|
|
4263
|
+
const preview = await imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, dryRun: true });
|
|
4125
4264
|
return createTextResult(withBulkNotFound(preview, notFoundEmailIds));
|
|
4126
4265
|
}
|
|
4127
|
-
const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, dryRun: false }));
|
|
4266
|
+
const result = await withAudit(auditService, name, args, () => imapService.bulkUpdateLabels({ emailIds, match, folder, labelsToAdd, labelsToRemove, resolvedUids: uids, dryRun: false }));
|
|
4128
4267
|
return createTextResult(withBulkNotFound(result, notFoundEmailIds));
|
|
4129
4268
|
}
|
|
4130
4269
|
case "top_senders": {
|
|
@@ -4222,20 +4361,27 @@ export function createServer(config, options = {}) {
|
|
|
4222
4361
|
ensureMailboxWriteAllowed(config.runtime);
|
|
4223
4362
|
ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args?.confirmed, false), "Permanently delete folder and all messages in it: " + requireString(args, "path"));
|
|
4224
4363
|
return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteFolder(requireString(args, "path"))));
|
|
4225
|
-
case "mark_email_read":
|
|
4364
|
+
case "mark_email_read": {
|
|
4226
4365
|
ensureEmailActionAllowed(config.runtime, normalizeBoolean(args.isRead, true) ? "mark_read" : "mark_unread");
|
|
4227
|
-
|
|
4228
|
-
|
|
4366
|
+
const emailId = requireString(args, "emailId");
|
|
4367
|
+
const uidValidity = parseEmailId(emailId).uidValidity;
|
|
4368
|
+
return createTextResult(await withAudit(auditService, name, args, async () => imapService.markEmailRead(emailId, normalizeBoolean(args.isRead, true), uidValidity)));
|
|
4369
|
+
}
|
|
4370
|
+
case "star_email": {
|
|
4229
4371
|
ensureEmailActionAllowed(config.runtime, normalizeBoolean(args.isStarred, true) ? "star" : "unstar");
|
|
4230
|
-
|
|
4372
|
+
const emailId = requireString(args, "emailId");
|
|
4373
|
+
const uidValidity = parseEmailId(emailId).uidValidity;
|
|
4374
|
+
return createTextResult(await withAudit(auditService, name, args, async () => imapService.starEmail(emailId, normalizeBoolean(args.isStarred, true), uidValidity)));
|
|
4375
|
+
}
|
|
4231
4376
|
case "move_email":
|
|
4232
4377
|
{
|
|
4233
4378
|
ensureEmailActionAllowed(config.runtime, "move");
|
|
4234
4379
|
const emailId = requireString(args, "emailId");
|
|
4235
4380
|
const targetFolder = requireString(args, "targetFolder");
|
|
4381
|
+
const uidValidity = parseEmailId(emailId).uidValidity;
|
|
4236
4382
|
const result = await withAudit(auditService, name, args, async () => {
|
|
4237
4383
|
try {
|
|
4238
|
-
return await imapService.moveEmail(emailId, targetFolder);
|
|
4384
|
+
return await imapService.moveEmail(emailId, targetFolder, uidValidity);
|
|
4239
4385
|
}
|
|
4240
4386
|
catch (error) {
|
|
4241
4387
|
if (isMissingTargetFolderError(error)) {
|
|
@@ -4260,7 +4406,9 @@ export function createServer(config, options = {}) {
|
|
|
4260
4406
|
case "archive_email":
|
|
4261
4407
|
{
|
|
4262
4408
|
ensureEmailActionAllowed(config.runtime, "archive");
|
|
4263
|
-
const
|
|
4409
|
+
const emailId = requireString(args, "emailId");
|
|
4410
|
+
const uidValidity = parseEmailId(emailId).uidValidity;
|
|
4411
|
+
const result = await withAudit(auditService, name, args, async () => imapService.archiveEmail(emailId, uidValidity));
|
|
4264
4412
|
const sources = result.targetEmailId
|
|
4265
4413
|
? [
|
|
4266
4414
|
{
|
|
@@ -4277,7 +4425,9 @@ export function createServer(config, options = {}) {
|
|
|
4277
4425
|
case "trash_email":
|
|
4278
4426
|
{
|
|
4279
4427
|
ensureEmailActionAllowed(config.runtime, "trash");
|
|
4280
|
-
const
|
|
4428
|
+
const emailId = requireString(args, "emailId");
|
|
4429
|
+
const uidValidity = parseEmailId(emailId).uidValidity;
|
|
4430
|
+
const result = await withAudit(auditService, name, args, async () => imapService.trashEmail(emailId, uidValidity));
|
|
4281
4431
|
const sources = result.targetEmailId
|
|
4282
4432
|
? [
|
|
4283
4433
|
{
|
|
@@ -4294,7 +4444,9 @@ export function createServer(config, options = {}) {
|
|
|
4294
4444
|
case "restore_email":
|
|
4295
4445
|
{
|
|
4296
4446
|
ensureEmailActionAllowed(config.runtime, "restore");
|
|
4297
|
-
const
|
|
4447
|
+
const emailId = requireString(args, "emailId");
|
|
4448
|
+
const uidValidity = parseEmailId(emailId).uidValidity;
|
|
4449
|
+
const result = await withAudit(auditService, name, args, async () => imapService.restoreEmail(emailId, optionalString(args, "targetFolder"), uidValidity));
|
|
4298
4450
|
const sources = result.targetEmailId
|
|
4299
4451
|
? [
|
|
4300
4452
|
{
|
|
@@ -4367,10 +4519,13 @@ export function createServer(config, options = {}) {
|
|
|
4367
4519
|
const result = await withAudit(auditService, name, args, async () => templateService.render(requireString(args, "id"), variables));
|
|
4368
4520
|
return createTextResult(result);
|
|
4369
4521
|
}
|
|
4370
|
-
case "delete_email":
|
|
4522
|
+
case "delete_email": {
|
|
4371
4523
|
ensureDestructiveConfirmed(config.runtime, normalizeBoolean(args.confirmed, false), `Permanently delete ${String(args.emailId ?? "?")} (cannot be recovered)`);
|
|
4372
4524
|
ensureMailboxWriteAllowed(config.runtime);
|
|
4373
|
-
|
|
4525
|
+
const emailId = requireString(args, "emailId");
|
|
4526
|
+
const uidValidity = parseEmailId(emailId).uidValidity;
|
|
4527
|
+
return createTextResult(await withAudit(auditService, name, args, async () => imapService.deleteEmail(emailId, uidValidity)));
|
|
4528
|
+
}
|
|
4374
4529
|
case "batch_email_action":
|
|
4375
4530
|
{
|
|
4376
4531
|
const emailIds = [...new Set(parseStringListArg(args, "emailIds"))];
|
|
@@ -4609,7 +4764,7 @@ export function createServer(config, options = {}) {
|
|
|
4609
4764
|
},
|
|
4610
4765
|
drafts: {
|
|
4611
4766
|
total: drafts.length,
|
|
4612
|
-
active: drafts.filter((draft) => draft.status
|
|
4767
|
+
active: drafts.filter((draft) => draft.status !== "sent").length,
|
|
4613
4768
|
remoteSynced: drafts.filter((draft) => draft.remoteSyncState === "synced").length,
|
|
4614
4769
|
syncFailed: drafts.filter((draft) => draft.remoteSyncState === "sync_failed").length,
|
|
4615
4770
|
},
|