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 +2 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +178 -43
- package/dist/index.js.map +1 -1
- package/dist/services/simple-imap-service.d.ts.map +1 -1
- package/dist/services/simple-imap-service.js +9 -1
- package/dist/services/simple-imap-service.js.map +1 -1
- package/dist/services/smtp-service.d.ts +2 -1
- package/dist/services/smtp-service.d.ts.map +1 -1
- package/dist/services/smtp-service.js +37 -2
- package/dist/services/smtp-service.js.map +1 -1
- package/package.json +1 -1
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
|
-
- `
|
|
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;
|
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,EAAmC,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAC1F,OAAO,EAAE,aAAa,EAAE,MAAM,8BAA8B,CAAC;AAE7D,OAAO,KAAK,EAMV,mBAAmB,EAEnB,WAAW,EAIX,WAAW,EAIX,gBAAgB,EAChB,cAAc,EAGf,MAAM,kBAAkB,CAAC;AAmB1B,OAAO,EAAE,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,+BAA+B,CAAC;
|
|
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
|
|
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
|
|
4045
|
-
// the full
|
|
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
|
-
//
|
|
4220
|
-
// drafts only — a
|
|
4221
|
-
//
|
|
4222
|
-
|
|
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:
|
|
4225
|
-
|
|
4226
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
4318
|
-
//
|
|
4319
|
-
//
|
|
4320
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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 () =>
|
|
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(
|
|
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
|
-
|
|
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
|
|
4721
|
-
|
|
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),
|