apple-mail-mcp 2.4.2 → 2.5.0
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/build/index.js +98 -7
- package/build/services/appleMailManager.d.ts +37 -2
- package/build/services/appleMailManager.d.ts.map +1 -1
- package/build/services/appleMailManager.js +106 -10
- package/build/services/imapClient.js +1 -1
- package/build/services/replyForward.d.ts +87 -0
- package/build/services/replyForward.d.ts.map +1 -0
- package/build/services/replyForward.js +150 -0
- package/build/services/smtpMailer.d.ts +43 -0
- package/build/services/smtpMailer.d.ts.map +1 -1
- package/build/services/smtpMailer.js +55 -0
- package/package.json +1 -1
package/build/index.js
CHANGED
|
@@ -26,7 +26,8 @@ import { z } from "zod";
|
|
|
26
26
|
import { AppleMailManager, isPathWithinAllowedRoots } from "./services/appleMailManager.js";
|
|
27
27
|
import { writeFileSync } from "fs";
|
|
28
28
|
import { resolve as resolvePath, join as joinPath } from "path";
|
|
29
|
-
import { sendViaSmtp, shouldUseSmtp } from "./services/smtpMailer.js";
|
|
29
|
+
import { sendViaSmtp, sendSerialViaSmtp, shouldUseSmtp, isSmtpConfigured, resolveSmtpConfig, } from "./services/smtpMailer.js";
|
|
30
|
+
import { buildReplyOptions, buildForwardOptions, parseOriginalHeaders, } from "./services/replyForward.js";
|
|
30
31
|
import { isImapAccount, resolveImapConfigs, dropAllPools, imapSearchMessages, imapListMessages, imapUnreadCount, imapListMailboxes, imapMailStats, imapListAttachments, imapFetchAttachment, imapBatchMarkRead, imapBatchMarkUnread, imapBatchFlag, imapBatchUnflag, imapBatchDelete, imapBatchMove, imapThread, imapCreateMailbox, imapDeleteMailbox, imapRenameMailbox, imapGetMessage, imapMarkRead, imapMarkUnread, imapFlagMessage, imapUnflagMessage, imapMoveMessageById, imapDeleteMessageById, } from "./services/imapClient.js";
|
|
31
32
|
import { successResponse, errorResponse, partialCoverageBlock, withErrorHandling, messageSummary, } from "./tools/respond.js";
|
|
32
33
|
import { routeMessage } from "./services/messageRouter.js";
|
|
@@ -504,8 +505,13 @@ server.registerTool("send-serial-email", {
|
|
|
504
505
|
.passthrough())
|
|
505
506
|
.optional(),
|
|
506
507
|
},
|
|
507
|
-
}, withErrorHandling(({ recipients, subject, body, account, delayMs }) => {
|
|
508
|
-
|
|
508
|
+
}, withErrorHandling(async ({ recipients, subject, body, account, delayMs }) => {
|
|
509
|
+
// 2.5.0: prefer direct SMTP for mail-merge when configured (and not targeting
|
|
510
|
+
// a bare Mail.app account label); Mail.app fallback when not configured.
|
|
511
|
+
const smtpCfg = shouldUseSmtp(undefined, account) ? resolveSmtpOrFallback() : null;
|
|
512
|
+
const results = smtpCfg
|
|
513
|
+
? await sendSerialViaSmtp(recipients, subject, body, smtpCfg, { delayMs })
|
|
514
|
+
: mailManager.sendSerialEmail(recipients, subject, body, account, delayMs);
|
|
509
515
|
const successCount = results.filter((r) => r.success).length;
|
|
510
516
|
const failCount = results.length - successCount;
|
|
511
517
|
const details = results
|
|
@@ -557,6 +563,64 @@ server.registerTool("create-draft", {
|
|
|
557
563
|
attachmentCount,
|
|
558
564
|
});
|
|
559
565
|
}, "Error creating draft"));
|
|
566
|
+
/** Resolve SMTP config, falling back (host/user set but no password) to Mail.app. */
|
|
567
|
+
function resolveSmtpOrFallback() {
|
|
568
|
+
try {
|
|
569
|
+
return resolveSmtpConfig();
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/** Reply to a message over direct SMTP with RFC 5322 threading headers. */
|
|
576
|
+
async function sendReplyViaSmtp(id, body, replyAll) {
|
|
577
|
+
const cfg = resolveSmtpOrFallback();
|
|
578
|
+
if (!cfg)
|
|
579
|
+
return { sent: false, fallback: true };
|
|
580
|
+
const raw = mailManager.getRawSource(id);
|
|
581
|
+
if (!raw)
|
|
582
|
+
return { sent: false, fallback: true };
|
|
583
|
+
const original = parseOriginalHeaders(raw);
|
|
584
|
+
// Without a Message-ID we can't thread; let Mail.app's reply handle it.
|
|
585
|
+
if (!original.messageId || original.from.length === 0) {
|
|
586
|
+
return { sent: false, fallback: true };
|
|
587
|
+
}
|
|
588
|
+
const content = mailManager.getMessageContent(id);
|
|
589
|
+
const opts = buildReplyOptions({
|
|
590
|
+
original,
|
|
591
|
+
originalPlainText: content?.plainText ?? "",
|
|
592
|
+
body,
|
|
593
|
+
replyAll,
|
|
594
|
+
self: [cfg.from, cfg.user],
|
|
595
|
+
from: cfg.from,
|
|
596
|
+
});
|
|
597
|
+
const result = await sendViaSmtp(opts, cfg);
|
|
598
|
+
if (result.success)
|
|
599
|
+
return { sent: true };
|
|
600
|
+
return { sent: false, fallback: false, error: result.error ?? "unknown SMTP error" };
|
|
601
|
+
}
|
|
602
|
+
/** Forward a message over direct SMTP (clean MIME, new thread). */
|
|
603
|
+
async function sendForwardViaSmtp(id, to, body) {
|
|
604
|
+
const cfg = resolveSmtpOrFallback();
|
|
605
|
+
if (!cfg)
|
|
606
|
+
return { sent: false, fallback: true };
|
|
607
|
+
const raw = mailManager.getRawSource(id);
|
|
608
|
+
if (!raw)
|
|
609
|
+
return { sent: false, fallback: true };
|
|
610
|
+
const original = parseOriginalHeaders(raw);
|
|
611
|
+
const content = mailManager.getMessageContent(id);
|
|
612
|
+
const opts = buildForwardOptions({
|
|
613
|
+
original,
|
|
614
|
+
originalPlainText: content?.plainText ?? "",
|
|
615
|
+
to,
|
|
616
|
+
body,
|
|
617
|
+
from: cfg.from,
|
|
618
|
+
});
|
|
619
|
+
const result = await sendViaSmtp(opts, cfg);
|
|
620
|
+
if (result.success)
|
|
621
|
+
return { sent: true };
|
|
622
|
+
return { sent: false, fallback: false, error: result.error ?? "unknown SMTP error" };
|
|
623
|
+
}
|
|
560
624
|
// --- reply-to-message ---
|
|
561
625
|
server.registerTool("reply-to-message", {
|
|
562
626
|
description: "Use when: replying to an existing message by id, preserving its threading headers. Set replyAll for all recipients; set send=false to save as a draft instead of sending.\nReturns: a confirmation that the reply was sent or saved as a draft.\nDo not use when: composing a brand-new message (use send-email / create-draft) or forwarding to new recipients (use forward-message).\nSafety: with the default send=true this SENDS real email immediately and cannot be unsent — require explicit user confirmation of the recipients and body, or pass send=false to let the user review.",
|
|
@@ -575,7 +639,19 @@ server.registerTool("reply-to-message", {
|
|
|
575
639
|
sent: z.boolean().optional(),
|
|
576
640
|
id: z.string().optional(),
|
|
577
641
|
},
|
|
578
|
-
}, withErrorHandling(({ id, body, replyAll, send }) => {
|
|
642
|
+
}, withErrorHandling(async ({ id, body, replyAll, send }) => {
|
|
643
|
+
// 2.5.0: prefer direct SMTP (clean, correctly threaded MIME) when configured
|
|
644
|
+
// and actually sending. Drafts (send=false) and the not-configured /
|
|
645
|
+
// unthreadable cases fall through to the Mail.app AppleScript path.
|
|
646
|
+
if (send && isSmtpConfigured()) {
|
|
647
|
+
const outcome = await sendReplyViaSmtp(id, body, replyAll);
|
|
648
|
+
if (outcome.sent) {
|
|
649
|
+
return successResponse("Reply sent", { ok: true, sent: true, id });
|
|
650
|
+
}
|
|
651
|
+
if (!outcome.fallback) {
|
|
652
|
+
return errorResponse(`Failed to reply to message "${id}" via SMTP: ${outcome.error}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
579
655
|
const success = mailManager.replyToMessage(id, body, replyAll, send);
|
|
580
656
|
if (!success) {
|
|
581
657
|
return errorResponse(`Failed to reply to message "${id}"`);
|
|
@@ -605,7 +681,22 @@ server.registerTool("forward-message", {
|
|
|
605
681
|
recipients: z.array(z.string()).optional(),
|
|
606
682
|
id: z.string().optional(),
|
|
607
683
|
},
|
|
608
|
-
}, withErrorHandling(({ id, to, body, send }) => {
|
|
684
|
+
}, withErrorHandling(async ({ id, to, body, send }) => {
|
|
685
|
+
// 2.5.0: prefer direct SMTP (clean MIME) when configured and actually sending.
|
|
686
|
+
if (send && isSmtpConfigured()) {
|
|
687
|
+
const outcome = await sendForwardViaSmtp(id, to, body);
|
|
688
|
+
if (outcome.sent) {
|
|
689
|
+
return successResponse(`Message forwarded to ${to.join(", ")}`, {
|
|
690
|
+
ok: true,
|
|
691
|
+
sent: true,
|
|
692
|
+
recipients: to,
|
|
693
|
+
id,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
if (!outcome.fallback) {
|
|
697
|
+
return errorResponse(`Failed to forward message "${id}" via SMTP: ${outcome.error}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
609
700
|
const success = mailManager.forwardMessage(id, to, body, send);
|
|
610
701
|
if (!success) {
|
|
611
702
|
return errorResponse(`Failed to forward message "${id}"`);
|
|
@@ -1036,9 +1127,9 @@ server.registerTool("create-mailbox", {
|
|
|
1036
1127
|
return errorResponse(r.error || `Failed to create mailbox "${name}"`);
|
|
1037
1128
|
return successResponse(r.info || `Mailbox "${name}" created`, { ok: true, name });
|
|
1038
1129
|
}
|
|
1039
|
-
const success = mailManager.createMailbox(name, account);
|
|
1130
|
+
const { success, error } = mailManager.createMailbox(name, account);
|
|
1040
1131
|
if (!success) {
|
|
1041
|
-
return errorResponse(`Failed to create mailbox "${name}"`);
|
|
1132
|
+
return errorResponse(error || `Failed to create mailbox "${name}"`);
|
|
1042
1133
|
}
|
|
1043
1134
|
return successResponse(`Mailbox "${name}" created`, { ok: true, name });
|
|
1044
1135
|
}, "Error creating mailbox"));
|
|
@@ -53,7 +53,7 @@ export declare function isPathWithinAllowedRoots(resolvedPath: string): boolean;
|
|
|
53
53
|
*
|
|
54
54
|
* Exported for unit testing.
|
|
55
55
|
*/
|
|
56
|
-
export declare function describeMailboxOpError(op: "delete" | "rename", raw: string): string;
|
|
56
|
+
export declare function describeMailboxOpError(op: "create" | "delete" | "rename", raw: string): string;
|
|
57
57
|
/** Env var to pin the default account (matched by account name or email). */
|
|
58
58
|
export declare const DEFAULT_ACCOUNT_ENV = "APPLE_MAIL_MCP_DEFAULT_ACCOUNT";
|
|
59
59
|
/**
|
|
@@ -153,6 +153,38 @@ export declare class AppleMailManager {
|
|
|
153
153
|
* mailbox structure (create/delete/rename mailbox).
|
|
154
154
|
*/
|
|
155
155
|
private invalidateCache;
|
|
156
|
+
/**
|
|
157
|
+
* Reads the live `enabled` flag for an account directly from Mail (bypassing
|
|
158
|
+
* the 60 s account cache) so a guard reflects an account that was enabled or
|
|
159
|
+
* disabled out-of-band. Returns true/false when known, or null when the probe
|
|
160
|
+
* is inconclusive — account not found, or the probe itself failed. Callers
|
|
161
|
+
* treat null as "can't tell, don't block".
|
|
162
|
+
*/
|
|
163
|
+
private isAccountEnabled;
|
|
164
|
+
/**
|
|
165
|
+
* Guard for AppleScript-backed structural operations (create / delete / rename
|
|
166
|
+
* mailbox). When the target account is disabled in Mail, Mail holds no live
|
|
167
|
+
* server session for it, so the operation fails inside Mail with an opaque
|
|
168
|
+
* AppleEvent -10000 — and a multi-step op like rename can leave half-built
|
|
169
|
+
* state behind (an orphaned destination mailbox). Detect the disabled account
|
|
170
|
+
* up front and refuse with an actionable message instead of attempting the
|
|
171
|
+
* doomed op.
|
|
172
|
+
*
|
|
173
|
+
* Returns an error string when the account is known-disabled, else null —
|
|
174
|
+
* including when the state can't be determined. We fail open: an inconclusive
|
|
175
|
+
* probe never blocks an otherwise-valid operation.
|
|
176
|
+
*
|
|
177
|
+
* Applies only to the AppleScript backend. Direct-IMAP accounts talk to the
|
|
178
|
+
* server independent of Mail's enabled toggle and are routed before reaching
|
|
179
|
+
* the manager.
|
|
180
|
+
*/
|
|
181
|
+
private disabledAccountGuard;
|
|
182
|
+
/**
|
|
183
|
+
* Best-effort rollback for a failed rename: delete a just-created destination
|
|
184
|
+
* mailbox, but ONLY if it is empty, so any messages that did move are never
|
|
185
|
+
* destroyed. Returns true if the empty orphan was removed.
|
|
186
|
+
*/
|
|
187
|
+
private deleteMailboxIfEmpty;
|
|
156
188
|
/**
|
|
157
189
|
* Resolves the account to use for an operation when the caller omits one.
|
|
158
190
|
*
|
|
@@ -479,7 +511,10 @@ export declare class AppleMailManager {
|
|
|
479
511
|
/**
|
|
480
512
|
* Create a new mailbox.
|
|
481
513
|
*/
|
|
482
|
-
createMailbox(name: string, account?: string):
|
|
514
|
+
createMailbox(name: string, account?: string): {
|
|
515
|
+
success: boolean;
|
|
516
|
+
error?: string;
|
|
517
|
+
};
|
|
483
518
|
/**
|
|
484
519
|
* Delete a mailbox.
|
|
485
520
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"appleMailManager.d.ts","sourceRoot":"","sources":["../../src/services/appleMailManager.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAUH,OAAO,KAAK,EACV,OAAO,EACP,cAAc,EACd,OAAO,EACP,OAAO,EACP,UAAU,EACV,iBAAiB,EACjB,SAAS,EAET,oBAAoB,EACpB,UAAU,EACV,qBAAqB,EACrB,QAAQ,EACR,QAAQ,EAGR,eAAe,EACf,OAAO,EACP,aAAa,EACb,oBAAoB,EACpB,iBAAiB,EACjB,iBAAiB,EACjB,YAAY,EACb,MAAM,YAAY,CAAC;AA2DpB;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,iBAAiB,EAAE,IAAI,EAAE,iBAAiB,GAAG,IAAI,CAK7F;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,iBAAiB,CAAA;CAAE,CAsCrD;AAqBD;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAKtE;AAWD;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CAAC,EAAE,EAAE,QAAQ,GAAG,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"appleMailManager.d.ts","sourceRoot":"","sources":["../../src/services/appleMailManager.ts"],"names":[],"mappings":"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"}
|
|
@@ -178,7 +178,7 @@ const UNSUPPORTED_APPLESCRIPT_OP = /AppleEvent handler failed|-10000/i;
|
|
|
178
178
|
export function describeMailboxOpError(op, raw) {
|
|
179
179
|
const trimmed = (raw || "").trim();
|
|
180
180
|
if (UNSUPPORTED_APPLESCRIPT_OP.test(trimmed)) {
|
|
181
|
-
const verb = op
|
|
181
|
+
const verb = op.charAt(0).toUpperCase() + op.slice(1);
|
|
182
182
|
return `Mail.app cannot ${op} server-side (IMAP / Gmail / Workspace / iCloud / Exchange) mailboxes via AppleScript — only local "On My Mac" mailboxes support this. ${verb} it in Mail.app directly. (Mail.app error: ${trimmed})`;
|
|
183
183
|
}
|
|
184
184
|
return trimmed || `Failed to ${op} mailbox`;
|
|
@@ -523,6 +523,77 @@ export class AppleMailManager {
|
|
|
523
523
|
this.cache.accounts = null;
|
|
524
524
|
this.cache.mailboxNames.clear();
|
|
525
525
|
}
|
|
526
|
+
/**
|
|
527
|
+
* Reads the live `enabled` flag for an account directly from Mail (bypassing
|
|
528
|
+
* the 60 s account cache) so a guard reflects an account that was enabled or
|
|
529
|
+
* disabled out-of-band. Returns true/false when known, or null when the probe
|
|
530
|
+
* is inconclusive — account not found, or the probe itself failed. Callers
|
|
531
|
+
* treat null as "can't tell, don't block".
|
|
532
|
+
*/
|
|
533
|
+
isAccountEnabled(account) {
|
|
534
|
+
const safeAccount = escapeForAppleScript(account);
|
|
535
|
+
const result = executeAppleScript(buildAppLevelScript(`
|
|
536
|
+
try
|
|
537
|
+
return (enabled of account "${safeAccount}") as text
|
|
538
|
+
on error
|
|
539
|
+
return "missing"
|
|
540
|
+
end try
|
|
541
|
+
`));
|
|
542
|
+
if (!result.success)
|
|
543
|
+
return null;
|
|
544
|
+
const out = result.output.trim();
|
|
545
|
+
if (out === "true")
|
|
546
|
+
return true;
|
|
547
|
+
if (out === "false")
|
|
548
|
+
return false;
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Guard for AppleScript-backed structural operations (create / delete / rename
|
|
553
|
+
* mailbox). When the target account is disabled in Mail, Mail holds no live
|
|
554
|
+
* server session for it, so the operation fails inside Mail with an opaque
|
|
555
|
+
* AppleEvent -10000 — and a multi-step op like rename can leave half-built
|
|
556
|
+
* state behind (an orphaned destination mailbox). Detect the disabled account
|
|
557
|
+
* up front and refuse with an actionable message instead of attempting the
|
|
558
|
+
* doomed op.
|
|
559
|
+
*
|
|
560
|
+
* Returns an error string when the account is known-disabled, else null —
|
|
561
|
+
* including when the state can't be determined. We fail open: an inconclusive
|
|
562
|
+
* probe never blocks an otherwise-valid operation.
|
|
563
|
+
*
|
|
564
|
+
* Applies only to the AppleScript backend. Direct-IMAP accounts talk to the
|
|
565
|
+
* server independent of Mail's enabled toggle and are routed before reaching
|
|
566
|
+
* the manager.
|
|
567
|
+
*/
|
|
568
|
+
disabledAccountGuard(account) {
|
|
569
|
+
if (this.isAccountEnabled(account) === false) {
|
|
570
|
+
return `Account "${account}" is disabled in Mail, so Mail has no live connection to it — this operation would fail server-side (AppleEvent -10000) and could leave a mailbox half-changed. Enable the account (Mail ▸ Settings ▸ Accounts ▸ "Enable this account") and retry, or target an enabled account.`;
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Best-effort rollback for a failed rename: delete a just-created destination
|
|
576
|
+
* mailbox, but ONLY if it is empty, so any messages that did move are never
|
|
577
|
+
* destroyed. Returns true if the empty orphan was removed.
|
|
578
|
+
*/
|
|
579
|
+
deleteMailboxIfEmpty(name, account) {
|
|
580
|
+
const safeName = escapeForAppleScript(name);
|
|
581
|
+
const safeAccount = escapeForAppleScript(account);
|
|
582
|
+
const result = executeAppleScript(buildAppLevelScript(`
|
|
583
|
+
try
|
|
584
|
+
set mb to mailbox "${safeName}" of account "${safeAccount}"
|
|
585
|
+
if (count of messages of mb) is 0 then
|
|
586
|
+
delete mb
|
|
587
|
+
return "deleted"
|
|
588
|
+
else
|
|
589
|
+
return "kept"
|
|
590
|
+
end if
|
|
591
|
+
on error errMsg
|
|
592
|
+
return "error:" & errMsg
|
|
593
|
+
end try
|
|
594
|
+
`));
|
|
595
|
+
return result.success && result.output.trim() === "deleted";
|
|
596
|
+
}
|
|
526
597
|
/**
|
|
527
598
|
* Resolves the account to use for an operation when the caller omits one.
|
|
528
599
|
*
|
|
@@ -2050,6 +2121,11 @@ export class AppleMailManager {
|
|
|
2050
2121
|
*/
|
|
2051
2122
|
createMailbox(name, account) {
|
|
2052
2123
|
const targetAccount = this.resolveAccount(account);
|
|
2124
|
+
const disabled = this.disabledAccountGuard(targetAccount);
|
|
2125
|
+
if (disabled) {
|
|
2126
|
+
console.error(`Refusing to create mailbox: ${disabled}`);
|
|
2127
|
+
return { success: false, error: disabled };
|
|
2128
|
+
}
|
|
2053
2129
|
const safeName = escapeForAppleScript(name);
|
|
2054
2130
|
const safeAccount = escapeForAppleScript(targetAccount);
|
|
2055
2131
|
const script = buildAppLevelScript(`
|
|
@@ -2062,17 +2138,26 @@ export class AppleMailManager {
|
|
|
2062
2138
|
`);
|
|
2063
2139
|
const result = executeAppleScript(script);
|
|
2064
2140
|
if (!result.success || result.output.startsWith("error:")) {
|
|
2065
|
-
|
|
2066
|
-
|
|
2141
|
+
const raw = result.success
|
|
2142
|
+
? result.output.replace(/^error:/, "")
|
|
2143
|
+
: result.error || "Unknown error";
|
|
2144
|
+
const error = describeMailboxOpError("create", raw);
|
|
2145
|
+
console.error(`Failed to create mailbox: ${error}`);
|
|
2146
|
+
return { success: false, error };
|
|
2067
2147
|
}
|
|
2068
2148
|
this.invalidateCache();
|
|
2069
|
-
return true;
|
|
2149
|
+
return { success: true };
|
|
2070
2150
|
}
|
|
2071
2151
|
/**
|
|
2072
2152
|
* Delete a mailbox.
|
|
2073
2153
|
*/
|
|
2074
2154
|
deleteMailbox(name, account) {
|
|
2075
2155
|
const targetAccount = this.resolveAccount(account);
|
|
2156
|
+
const disabled = this.disabledAccountGuard(targetAccount);
|
|
2157
|
+
if (disabled) {
|
|
2158
|
+
console.error(`Refusing to delete mailbox: ${disabled}`);
|
|
2159
|
+
return { success: false, error: disabled };
|
|
2160
|
+
}
|
|
2076
2161
|
const targetMailbox = this.resolveMailbox(name, targetAccount);
|
|
2077
2162
|
const safeName = escapeForAppleScript(targetMailbox);
|
|
2078
2163
|
const safeAccount = escapeForAppleScript(targetAccount);
|
|
@@ -2101,11 +2186,15 @@ export class AppleMailManager {
|
|
|
2101
2186
|
*/
|
|
2102
2187
|
renameMailbox(oldName, newName, account) {
|
|
2103
2188
|
const targetAccount = this.resolveAccount(account);
|
|
2104
|
-
// Create the new mailbox
|
|
2105
|
-
|
|
2189
|
+
// Create the new mailbox. createMailbox runs the disabled-account guard, so
|
|
2190
|
+
// a disabled target is refused here before anything is built — no orphan can
|
|
2191
|
+
// be created. Propagate its (more specific) error rather than a generic one.
|
|
2192
|
+
const created = this.createMailbox(newName, targetAccount);
|
|
2193
|
+
if (!created.success) {
|
|
2106
2194
|
return {
|
|
2107
2195
|
success: false,
|
|
2108
|
-
error:
|
|
2196
|
+
error: created.error ??
|
|
2197
|
+
`Could not create the destination mailbox "${newName}" needed for the rename.`,
|
|
2109
2198
|
};
|
|
2110
2199
|
}
|
|
2111
2200
|
// Move all messages from old to new
|
|
@@ -2155,9 +2244,16 @@ export class AppleMailManager {
|
|
|
2155
2244
|
const raw = result.success
|
|
2156
2245
|
? result.output.replace(/^error:/, "")
|
|
2157
2246
|
: result.error || "Unknown error";
|
|
2158
|
-
//
|
|
2159
|
-
//
|
|
2160
|
-
|
|
2247
|
+
// Roll back the destination we just created so a failed rename doesn't
|
|
2248
|
+
// leave an orphan (as a partial-failure once did — the _amcp_rename_test_*
|
|
2249
|
+
// ghosts). deleteMailboxIfEmpty only removes it when empty, so any
|
|
2250
|
+
// messages that did move are never destroyed; the source is untouched
|
|
2251
|
+
// (its delete only runs after a verified-empty move).
|
|
2252
|
+
const rolledBack = this.deleteMailboxIfEmpty(resolvedNew, targetAccount);
|
|
2253
|
+
let error = describeMailboxOpError("rename", raw);
|
|
2254
|
+
error += rolledBack
|
|
2255
|
+
? ` The empty destination mailbox "${resolvedNew}" was rolled back, so no orphan was left.`
|
|
2256
|
+
: ` The destination mailbox "${resolvedNew}" was created and could not be auto-removed; delete it manually if it is an empty leftover.`;
|
|
2161
2257
|
console.error(`Failed to rename mailbox: ${error}`);
|
|
2162
2258
|
this.invalidateCache();
|
|
2163
2259
|
return { success: false, error };
|
|
@@ -851,7 +851,7 @@ async function imapBatch(ids, deps, op) {
|
|
|
851
851
|
errors.push(`Not an IMAP id: "${id}"`);
|
|
852
852
|
continue;
|
|
853
853
|
}
|
|
854
|
-
const key = `${ref.account}
|
|
854
|
+
const key = `${ref.account}\0${ref.path}`;
|
|
855
855
|
const g = groups.get(key) ?? { account: ref.account, path: ref.path, uids: [] };
|
|
856
856
|
g.uids.push(ref.uid);
|
|
857
857
|
groups.set(key, g);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers that turn an existing message (its raw RFC 5322 source plus the
|
|
3
|
+
* decoded plain-text body) into {@link SmtpSendOptions} for a **threaded reply**
|
|
4
|
+
* or a **forward** sent over SMTP.
|
|
5
|
+
*
|
|
6
|
+
* This is the 2.5.0 "prefer-direct" path: when SMTP is configured we send
|
|
7
|
+
* replies/forwards ourselves with correct `In-Reply-To`/`References` headers and
|
|
8
|
+
* a clean MIME body, instead of driving Mail.app's `reply`/`forward` AppleScript
|
|
9
|
+
* commands (which thread correctly but wrap the injected body in a `blockquote`
|
|
10
|
+
* on macOS 15+). Kept separate from {@link sendViaSmtp} so the addressing,
|
|
11
|
+
* subject-prefix, and quoting rules are unit-testable without a live SMTP server.
|
|
12
|
+
*/
|
|
13
|
+
import type { SmtpSendOptions } from "../services/smtpMailer.js";
|
|
14
|
+
/** The subset of an original message's headers we need to reply/forward. */
|
|
15
|
+
export interface OriginalHeaders {
|
|
16
|
+
/** Raw `Message-ID` including angle brackets, e.g. `<abc@host>`. */
|
|
17
|
+
messageId?: string;
|
|
18
|
+
/** Thread chain: every `<id>` from `References` + `In-Reply-To`, in order. */
|
|
19
|
+
references: string[];
|
|
20
|
+
/** `From` address(es), bare (no display name). */
|
|
21
|
+
from: string[];
|
|
22
|
+
/** `Reply-To` address(es), bare. Preferred over {@link from} for replies. */
|
|
23
|
+
replyTo: string[];
|
|
24
|
+
/** `To` address(es), bare. */
|
|
25
|
+
to: string[];
|
|
26
|
+
/** `Cc` address(es), bare. */
|
|
27
|
+
cc: string[];
|
|
28
|
+
/** `Subject`, unfolded and trimmed (no `Re:`/`Fwd:` normalization). */
|
|
29
|
+
subject: string;
|
|
30
|
+
/** `Date` header verbatim, for the reply attribution line. */
|
|
31
|
+
date?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Pull bare email addresses out of a header value such as
|
|
35
|
+
* `"Alice <a@x.com>, bob@y.com"` → `["a@x.com", "bob@y.com"]`. Splits on commas
|
|
36
|
+
* (address lists are comma-separated) and unwraps `<...>` when present. Anything
|
|
37
|
+
* without an `@` is dropped, so group syntax / junk is ignored.
|
|
38
|
+
*/
|
|
39
|
+
export declare function extractAddresses(headerValue: string): string[];
|
|
40
|
+
/**
|
|
41
|
+
* Parse the header block (everything before the first blank line) of a raw
|
|
42
|
+
* message. Continuation lines (folded headers, leading WSP) are unfolded, and
|
|
43
|
+
* header names are matched case-insensitively. Only the headers in
|
|
44
|
+
* {@link OriginalHeaders} are extracted.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseOriginalHeaders(raw: string): OriginalHeaders;
|
|
47
|
+
/**
|
|
48
|
+
* Ensure `subject` carries `prefix` exactly once. Existing `Re:`/`Fwd:`/`Fw:`
|
|
49
|
+
* prefixes (any case) are treated as already-present so we never stack them.
|
|
50
|
+
*/
|
|
51
|
+
export declare function withSubjectPrefix(subject: string, prefix: "Re:" | "Fwd:"): string;
|
|
52
|
+
/** Prefix every line of the original body with `> ` for the quoted reply block. */
|
|
53
|
+
export declare function quoteBody(plainText: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* Build {@link SmtpSendOptions} for a threaded reply.
|
|
56
|
+
*
|
|
57
|
+
* - recipients: `Reply-To` if the original had one, else `From`; with
|
|
58
|
+
* `replyAll`, the original `To`+`Cc` (minus our own addresses and the primary
|
|
59
|
+
* recipients) become `Cc`.
|
|
60
|
+
* - subject: `Re: ` prepended unless already present.
|
|
61
|
+
* - threading: `In-Reply-To` = original `Message-ID`; `References` = original
|
|
62
|
+
* chain + that `Message-ID`.
|
|
63
|
+
* - body: the new text, then an attribution line and the quoted original.
|
|
64
|
+
*/
|
|
65
|
+
export declare function buildReplyOptions(args: {
|
|
66
|
+
original: OriginalHeaders;
|
|
67
|
+
originalPlainText: string;
|
|
68
|
+
body: string;
|
|
69
|
+
replyAll: boolean;
|
|
70
|
+
/** Our own addresses, excluded from a reply-all recipient set. */
|
|
71
|
+
self: string[];
|
|
72
|
+
/** From override (defaults to the SMTP identity at send time when omitted). */
|
|
73
|
+
from?: string;
|
|
74
|
+
}): SmtpSendOptions;
|
|
75
|
+
/**
|
|
76
|
+
* Build {@link SmtpSendOptions} for a forward to new recipients. A forward
|
|
77
|
+
* starts a new thread, so no `In-Reply-To`/`References` are set — the win over
|
|
78
|
+
* the AppleScript path is a clean, un-wrapped MIME body.
|
|
79
|
+
*/
|
|
80
|
+
export declare function buildForwardOptions(args: {
|
|
81
|
+
original: OriginalHeaders;
|
|
82
|
+
originalPlainText: string;
|
|
83
|
+
to: string[];
|
|
84
|
+
body?: string;
|
|
85
|
+
from?: string;
|
|
86
|
+
}): SmtpSendOptions;
|
|
87
|
+
//# sourceMappingURL=replyForward.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"replyForward.d.ts","sourceRoot":"","sources":["../../src/services/replyForward.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAEhE,4EAA4E;AAC5E,MAAM,WAAW,eAAe;IAC9B,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8EAA8E;IAC9E,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,kDAAkD;IAClD,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,6EAA6E;IAC7E,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,8BAA8B;IAC9B,EAAE,EAAE,MAAM,EAAE,CAAC;IACb,8BAA8B;IAC9B,EAAE,EAAE,MAAM,EAAE,CAAC;IACb,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,EAAE,CAS9D;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,eAAe,CAkCjE;AAgBD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,MAAM,CAIjF;AAED,mFAAmF;AACnF,wBAAgB,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAMnD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,QAAQ,EAAE,eAAe,CAAC;IAC1B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,kEAAkE;IAClE,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,+EAA+E;IAC/E,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,eAAe,CAmClB;AAQD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE;IACxC,QAAQ,EAAE,eAAe,CAAC;IAC1B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,EAAE,EAAE,MAAM,EAAE,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,eAAe,CAsBlB"}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull bare email addresses out of a header value such as
|
|
3
|
+
* `"Alice <a@x.com>, bob@y.com"` → `["a@x.com", "bob@y.com"]`. Splits on commas
|
|
4
|
+
* (address lists are comma-separated) and unwraps `<...>` when present. Anything
|
|
5
|
+
* without an `@` is dropped, so group syntax / junk is ignored.
|
|
6
|
+
*/
|
|
7
|
+
export function extractAddresses(headerValue) {
|
|
8
|
+
if (!headerValue.trim())
|
|
9
|
+
return [];
|
|
10
|
+
return headerValue
|
|
11
|
+
.split(",")
|
|
12
|
+
.map((part) => {
|
|
13
|
+
const angle = part.match(/<([^>]+)>/);
|
|
14
|
+
return (angle ? angle[1] : part).trim();
|
|
15
|
+
})
|
|
16
|
+
.filter((addr) => addr.includes("@"));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parse the header block (everything before the first blank line) of a raw
|
|
20
|
+
* message. Continuation lines (folded headers, leading WSP) are unfolded, and
|
|
21
|
+
* header names are matched case-insensitively. Only the headers in
|
|
22
|
+
* {@link OriginalHeaders} are extracted.
|
|
23
|
+
*/
|
|
24
|
+
export function parseOriginalHeaders(raw) {
|
|
25
|
+
const headerBlock = raw.split(/\r?\n\r?\n/)[0] ?? "";
|
|
26
|
+
// Unfold: any line starting with a space/tab continues the previous header.
|
|
27
|
+
const lines = [];
|
|
28
|
+
for (const line of headerBlock.split(/\r?\n/)) {
|
|
29
|
+
if (/^[ \t]/.test(line) && lines.length > 0) {
|
|
30
|
+
lines[lines.length - 1] += " " + line.trim();
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
lines.push(line);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const get = (name) => {
|
|
37
|
+
const prefix = `${name.toLowerCase()}:`;
|
|
38
|
+
const found = lines.find((l) => l.toLowerCase().startsWith(prefix));
|
|
39
|
+
return found ? found.slice(found.indexOf(":") + 1).trim() : undefined;
|
|
40
|
+
};
|
|
41
|
+
const rawMessageId = get("Message-ID");
|
|
42
|
+
const messageId = rawMessageId?.match(/<[^>]+>/)?.[0] ?? rawMessageId ?? undefined;
|
|
43
|
+
const references = `${get("References") ?? ""} ${get("In-Reply-To") ?? ""}`.match(/<[^>]+>/g) ?? [];
|
|
44
|
+
return {
|
|
45
|
+
messageId,
|
|
46
|
+
references,
|
|
47
|
+
from: extractAddresses(get("From") ?? ""),
|
|
48
|
+
replyTo: extractAddresses(get("Reply-To") ?? ""),
|
|
49
|
+
to: extractAddresses(get("To") ?? ""),
|
|
50
|
+
cc: extractAddresses(get("Cc") ?? ""),
|
|
51
|
+
subject: get("Subject") ?? "",
|
|
52
|
+
date: get("Date"),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Case-insensitive de-dupe that preserves first-seen order and casing. */
|
|
56
|
+
function dedupe(addrs) {
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const a of addrs) {
|
|
60
|
+
const k = a.toLowerCase();
|
|
61
|
+
if (!seen.has(k)) {
|
|
62
|
+
seen.add(k);
|
|
63
|
+
out.push(a);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Ensure `subject` carries `prefix` exactly once. Existing `Re:`/`Fwd:`/`Fw:`
|
|
70
|
+
* prefixes (any case) are treated as already-present so we never stack them.
|
|
71
|
+
*/
|
|
72
|
+
export function withSubjectPrefix(subject, prefix) {
|
|
73
|
+
const s = subject.trim();
|
|
74
|
+
const present = prefix === "Re:" ? /^re:/i : /^(fwd?|fw):/i;
|
|
75
|
+
return present.test(s) ? s : `${prefix} ${s}`;
|
|
76
|
+
}
|
|
77
|
+
/** Prefix every line of the original body with `> ` for the quoted reply block. */
|
|
78
|
+
export function quoteBody(plainText) {
|
|
79
|
+
return plainText
|
|
80
|
+
.replace(/\s+$/, "")
|
|
81
|
+
.split(/\r?\n/)
|
|
82
|
+
.map((l) => (l ? `> ${l}` : ">"))
|
|
83
|
+
.join("\n");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build {@link SmtpSendOptions} for a threaded reply.
|
|
87
|
+
*
|
|
88
|
+
* - recipients: `Reply-To` if the original had one, else `From`; with
|
|
89
|
+
* `replyAll`, the original `To`+`Cc` (minus our own addresses and the primary
|
|
90
|
+
* recipients) become `Cc`.
|
|
91
|
+
* - subject: `Re: ` prepended unless already present.
|
|
92
|
+
* - threading: `In-Reply-To` = original `Message-ID`; `References` = original
|
|
93
|
+
* chain + that `Message-ID`.
|
|
94
|
+
* - body: the new text, then an attribution line and the quoted original.
|
|
95
|
+
*/
|
|
96
|
+
export function buildReplyOptions(args) {
|
|
97
|
+
const { original, originalPlainText, body, replyAll, self, from } = args;
|
|
98
|
+
const selfSet = new Set(self.filter(Boolean).map((s) => s.toLowerCase()));
|
|
99
|
+
const to = dedupe((original.replyTo.length ? original.replyTo : original.from).filter(Boolean));
|
|
100
|
+
const toSet = new Set(to.map((t) => t.toLowerCase()));
|
|
101
|
+
let cc;
|
|
102
|
+
if (replyAll) {
|
|
103
|
+
const extra = dedupe([...original.to, ...original.cc].filter((a) => !selfSet.has(a.toLowerCase()) && !toSet.has(a.toLowerCase())));
|
|
104
|
+
cc = extra.length ? extra : undefined;
|
|
105
|
+
}
|
|
106
|
+
const attribution = buildAttribution(original);
|
|
107
|
+
const quoted = originalPlainText.trim()
|
|
108
|
+
? `\n\n${attribution}${quoteBody(originalPlainText)}`
|
|
109
|
+
: "";
|
|
110
|
+
const references = dedupe(original.messageId ? [...original.references, original.messageId] : original.references);
|
|
111
|
+
return {
|
|
112
|
+
to,
|
|
113
|
+
cc,
|
|
114
|
+
subject: withSubjectPrefix(original.subject, "Re:"),
|
|
115
|
+
body: `${body}${quoted}`,
|
|
116
|
+
inReplyTo: original.messageId,
|
|
117
|
+
references: references.length ? references : undefined,
|
|
118
|
+
from,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** "On <date>, <sender> wrote:\n" — omits the date clause when unknown. */
|
|
122
|
+
function buildAttribution(original) {
|
|
123
|
+
const who = original.from[0] ?? original.replyTo[0] ?? "the sender";
|
|
124
|
+
return original.date ? `On ${original.date}, ${who} wrote:\n` : `${who} wrote:\n`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Build {@link SmtpSendOptions} for a forward to new recipients. A forward
|
|
128
|
+
* starts a new thread, so no `In-Reply-To`/`References` are set — the win over
|
|
129
|
+
* the AppleScript path is a clean, un-wrapped MIME body.
|
|
130
|
+
*/
|
|
131
|
+
export function buildForwardOptions(args) {
|
|
132
|
+
const { original, originalPlainText, to, body, from } = args;
|
|
133
|
+
const headerBlock = [
|
|
134
|
+
"---------- Forwarded message ----------",
|
|
135
|
+
original.from.length ? `From: ${original.from.join(", ")}` : "",
|
|
136
|
+
original.date ? `Date: ${original.date}` : "",
|
|
137
|
+
`Subject: ${original.subject}`,
|
|
138
|
+
original.to.length ? `To: ${original.to.join(", ")}` : "",
|
|
139
|
+
original.cc.length ? `Cc: ${original.cc.join(", ")}` : "",
|
|
140
|
+
]
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.join("\n");
|
|
143
|
+
const prefix = body?.trim() ? `${body}\n\n` : "";
|
|
144
|
+
return {
|
|
145
|
+
to: dedupe(to),
|
|
146
|
+
subject: withSubjectPrefix(original.subject, "Fwd:"),
|
|
147
|
+
body: `${prefix}${headerBlock}\n\n${originalPlainText}`,
|
|
148
|
+
from,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -36,6 +36,17 @@ export interface SmtpSendOptions {
|
|
|
36
36
|
* plain-text clients still get a clean fallback.
|
|
37
37
|
*/
|
|
38
38
|
htmlBody?: string;
|
|
39
|
+
/**
|
|
40
|
+
* RFC 5322 threading (2.5.0): the message this is replying to — emitted as the
|
|
41
|
+
* `In-Reply-To` header so SMTP replies/forwards thread correctly in Gmail and
|
|
42
|
+
* other clients. Pass the original message's `Message-ID`.
|
|
43
|
+
*/
|
|
44
|
+
inReplyTo?: string;
|
|
45
|
+
/**
|
|
46
|
+
* RFC 5322 threading (2.5.0): the `References` chain — the original message's
|
|
47
|
+
* existing `References` plus its `Message-ID`. nodemailer accepts an array.
|
|
48
|
+
*/
|
|
49
|
+
references?: string[];
|
|
39
50
|
}
|
|
40
51
|
/** Resolved SMTP connection configuration. */
|
|
41
52
|
export interface SmtpConfig {
|
|
@@ -114,4 +125,36 @@ export declare function resolveSmtpConfig(env?: NodeJS.ProcessEnv): SmtpConfig;
|
|
|
114
125
|
* a transporter factory only in tests.
|
|
115
126
|
*/
|
|
116
127
|
export declare function sendViaSmtp(opts: SmtpSendOptions, config?: SmtpConfig, createTransport?: typeof nodemailer.createTransport): Promise<SmtpSendResult>;
|
|
128
|
+
/** One recipient of a mail-merge batch (mirrors the AppleScript serial path). */
|
|
129
|
+
export interface SerialSmtpRecipient {
|
|
130
|
+
email: string;
|
|
131
|
+
variables: Record<string, string>;
|
|
132
|
+
}
|
|
133
|
+
/** Per-recipient outcome of a serial SMTP send. */
|
|
134
|
+
export interface SerialSmtpResult {
|
|
135
|
+
email: string;
|
|
136
|
+
success: boolean;
|
|
137
|
+
error?: string;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Replace every `{{Key}}` token in `template` with the matching value from
|
|
141
|
+
* `variables`. Keys are escaped so regex metacharacters in a key are literal.
|
|
142
|
+
* Mirrors the substitution in {@link AppleMailManager.sendSerialEmail} so the
|
|
143
|
+
* two transports personalize identically.
|
|
144
|
+
*/
|
|
145
|
+
export declare function applyPlaceholders(template: string, variables: Record<string, string>): string;
|
|
146
|
+
/**
|
|
147
|
+
* Send a personalized mail-merge batch over SMTP — one individual message per
|
|
148
|
+
* recipient (recipients never see each other), with `{{Key}}` placeholders in
|
|
149
|
+
* the subject/body replaced per recipient. Returns a per-recipient result list;
|
|
150
|
+
* a single recipient's failure does not abort the batch.
|
|
151
|
+
*
|
|
152
|
+
* `opts.send` and `opts.sleep` are injectable for tests (no real SMTP / no real
|
|
153
|
+
* delay). The default `sleep` waits `delayMs` (clamped 0–10000) between sends.
|
|
154
|
+
*/
|
|
155
|
+
export declare function sendSerialViaSmtp(recipients: SerialSmtpRecipient[], subject: string, body: string, config: SmtpConfig, opts?: {
|
|
156
|
+
delayMs?: number;
|
|
157
|
+
send?: typeof sendViaSmtp;
|
|
158
|
+
sleep?: (ms: number) => Promise<void>;
|
|
159
|
+
}): Promise<SerialSmtpResult[]>;
|
|
117
160
|
//# sourceMappingURL=smtpMailer.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"smtpMailer.d.ts","sourceRoot":"","sources":["../../src/services/smtpMailer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,UAAU,MAAM,YAAY,CAAC;AAIpC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD,8EAA8E;AAC9E,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,EAAE,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,kFAAkF;IAClF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,WAAW,CAAC,EAAE,eAAe,EAAE,CAAC;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"smtpMailer.d.ts","sourceRoot":"","sources":["../../src/services/smtpMailer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,UAAU,MAAM,YAAY,CAAC;AAIpC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAElD,8EAA8E;AAC9E,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,EAAE,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,kFAAkF;IAClF,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,WAAW,CAAC,EAAE,eAAe,EAAE,CAAC;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,8CAA8C;AAC9C,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,8BAA8B;AAC9B,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,eAAO,MAAM,QAAQ;;;;;;;;;CASX,CAAC;AAEX;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAE9E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,aAAa,GAAG,MAAM,GAAG,SAAS,EAC7C,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,UAAU,GAAE,OAA4B,GACvC,OAAO,CAMT;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAcpF;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,UAAU,CA8ClF;AAqBD;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,eAAe,EACrB,MAAM,CAAC,EAAE,UAAU,EACnB,eAAe,GAAE,OAAO,UAAU,CAAC,eAA4C,GAC9E,OAAO,CAAC,cAAc,CAAC,CAgDzB;AAED,iFAAiF;AACjF,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACnC;AAED,mDAAmD;AACnD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAO7F;AAED;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACrC,UAAU,EAAE,mBAAmB,EAAE,EACjC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,UAAU,EAClB,IAAI,GAAE;IACJ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,OAAO,WAAW,CAAC;IAC1B,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClC,GACL,OAAO,CAAC,gBAAgB,EAAE,CAAC,CA+B7B"}
|
|
@@ -198,6 +198,9 @@ export async function sendViaSmtp(opts, config, createTransport = nodemailer.cre
|
|
|
198
198
|
// When present, nodemailer emits multipart/alternative (text + html).
|
|
199
199
|
html,
|
|
200
200
|
attachments,
|
|
201
|
+
// RFC 5322 threading for SMTP replies/forwards (2.5.0).
|
|
202
|
+
inReplyTo: opts.inReplyTo?.trim() || undefined,
|
|
203
|
+
references: opts.references?.length ? opts.references : undefined,
|
|
201
204
|
});
|
|
202
205
|
return { success: true, messageId: info.messageId };
|
|
203
206
|
}
|
|
@@ -211,3 +214,55 @@ export async function sendViaSmtp(opts, config, createTransport = nodemailer.cre
|
|
|
211
214
|
transporter.close();
|
|
212
215
|
}
|
|
213
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Replace every `{{Key}}` token in `template` with the matching value from
|
|
219
|
+
* `variables`. Keys are escaped so regex metacharacters in a key are literal.
|
|
220
|
+
* Mirrors the substitution in {@link AppleMailManager.sendSerialEmail} so the
|
|
221
|
+
* two transports personalize identically.
|
|
222
|
+
*/
|
|
223
|
+
export function applyPlaceholders(template, variables) {
|
|
224
|
+
let out = template;
|
|
225
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
226
|
+
const safeKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
227
|
+
out = out.replace(new RegExp(`\\{\\{${safeKey}\\}\\}`, "g"), value);
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Send a personalized mail-merge batch over SMTP — one individual message per
|
|
233
|
+
* recipient (recipients never see each other), with `{{Key}}` placeholders in
|
|
234
|
+
* the subject/body replaced per recipient. Returns a per-recipient result list;
|
|
235
|
+
* a single recipient's failure does not abort the batch.
|
|
236
|
+
*
|
|
237
|
+
* `opts.send` and `opts.sleep` are injectable for tests (no real SMTP / no real
|
|
238
|
+
* delay). The default `sleep` waits `delayMs` (clamped 0–10000) between sends.
|
|
239
|
+
*/
|
|
240
|
+
export async function sendSerialViaSmtp(recipients, subject, body, config, opts = {}) {
|
|
241
|
+
const send = opts.send ?? sendViaSmtp;
|
|
242
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
243
|
+
const delay = Math.min(Math.max(opts.delayMs ?? 500, 0), 10000);
|
|
244
|
+
const results = [];
|
|
245
|
+
for (let i = 0; i < recipients.length; i++) {
|
|
246
|
+
const r = recipients[i];
|
|
247
|
+
try {
|
|
248
|
+
const res = await send({
|
|
249
|
+
to: [r.email],
|
|
250
|
+
subject: applyPlaceholders(subject, r.variables),
|
|
251
|
+
body: applyPlaceholders(body, r.variables),
|
|
252
|
+
from: config.from,
|
|
253
|
+
}, config);
|
|
254
|
+
results.push({ email: r.email, success: res.success, error: res.error });
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
results.push({
|
|
258
|
+
email: r.email,
|
|
259
|
+
success: false,
|
|
260
|
+
error: error instanceof Error ? error.message : String(error),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
if (delay > 0 && i < recipients.length - 1) {
|
|
264
|
+
await sleep(delay);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return results;
|
|
268
|
+
}
|
package/package.json
CHANGED