apple-mail-mcp 2.8.2 → 2.8.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/README.md +5 -5
- package/build/cli.js +12083 -138
- package/build/index.js +82684 -1687
- package/package.json +3 -4
- package/build/cli.d.ts +0 -24
- package/build/cli.d.ts.map +0 -1
- package/build/index.d.ts +0 -23
- package/build/index.d.ts.map +0 -1
- package/build/services/appleMailManager.d.ts +0 -680
- package/build/services/appleMailManager.d.ts.map +0 -1
- package/build/services/appleMailManager.js +0 -3143
- package/build/services/fileConfig.d.ts +0 -7
- package/build/services/fileConfig.d.ts.map +0 -1
- package/build/services/fileConfig.js +0 -52
- package/build/services/imapClient.d.ts +0 -312
- package/build/services/imapClient.d.ts.map +0 -1
- package/build/services/imapClient.js +0 -1023
- package/build/services/imapIdle.d.ts +0 -58
- package/build/services/imapIdle.d.ts.map +0 -1
- package/build/services/imapIdle.js +0 -151
- package/build/services/imapMultiAccount.d.ts +0 -124
- package/build/services/imapMultiAccount.d.ts.map +0 -1
- package/build/services/imapMultiAccount.js +0 -253
- package/build/services/messageRouter.d.ts +0 -24
- package/build/services/messageRouter.d.ts.map +0 -1
- package/build/services/messageRouter.js +0 -31
- package/build/services/replyForward.d.ts +0 -87
- package/build/services/replyForward.d.ts.map +0 -1
- package/build/services/replyForward.js +0 -150
- package/build/services/smtpMailer.d.ts +0 -160
- package/build/services/smtpMailer.d.ts.map +0 -1
- package/build/services/smtpMailer.js +0 -268
- package/build/services/templateStore.d.ts +0 -18
- package/build/services/templateStore.d.ts.map +0 -1
- package/build/services/templateStore.js +0 -91
- package/build/tools/doctor.d.ts +0 -23
- package/build/tools/doctor.d.ts.map +0 -1
- package/build/tools/doctor.js +0 -74
- package/build/tools/resourcesAndPrompts.d.ts +0 -14
- package/build/tools/resourcesAndPrompts.d.ts.map +0 -1
- package/build/tools/resourcesAndPrompts.js +0 -109
- package/build/tools/respond.d.ts +0 -48
- package/build/tools/respond.d.ts.map +0 -1
- package/build/tools/respond.js +0 -95
- package/build/tools/thread.d.ts +0 -19
- package/build/tools/thread.d.ts.map +0 -1
- package/build/tools/thread.js +0 -32
- package/build/types.d.ts +0 -434
- package/build/types.d.ts.map +0 -1
- package/build/types.js +0 -13
- package/build/utils/applescript.d.ts +0 -45
- package/build/utils/applescript.d.ts.map +0 -1
- package/build/utils/applescript.js +0 -446
- package/build/utils/attachmentMaterialize.d.ts +0 -9
- package/build/utils/attachmentMaterialize.d.ts.map +0 -1
- package/build/utils/attachmentMaterialize.js +0 -38
- package/build/utils/mimeParse.d.ts +0 -62
- package/build/utils/mimeParse.d.ts.map +0 -1
- package/build/utils/mimeParse.js +0 -317
- package/build/utils/orphan.d.ts +0 -25
- package/build/utils/orphan.d.ts.map +0 -1
- package/build/utils/orphan.js +0 -26
- package/build/utils/serialize.d.ts +0 -30
- package/build/utils/serialize.d.ts.map +0 -1
- package/build/utils/serialize.js +0 -41
|
@@ -1,87 +0,0 @@
|
|
|
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
|
|
@@ -1 +0,0 @@
|
|
|
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"}
|
|
@@ -1,150 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SMTP transport for sending mail (issue #12).
|
|
3
|
-
*
|
|
4
|
-
* Mail.app's AppleScript send path wraps any injected body in
|
|
5
|
-
* `<blockquote type="cite">` under the Apple-Mail-URLShareWrapperClass template
|
|
6
|
-
* on macOS 15+, so messages render to recipients as quoted/forwarded content
|
|
7
|
-
* (Apple radar FB11734014, open since Ventura). This module bypasses Mail.app
|
|
8
|
-
* entirely and submits clean MIME directly over SMTP via nodemailer.
|
|
9
|
-
*
|
|
10
|
-
* Connection settings come from environment variables; the password is read
|
|
11
|
-
* from the macOS Keychain via the `security` CLI by default so no secret is
|
|
12
|
-
* ever placed in config. When SMTP is configured, `send-email` auto-prefers it
|
|
13
|
-
* over AppleScript (opt out per call with `transport: "applescript"`); see
|
|
14
|
-
* {@link shouldUseSmtp}.
|
|
15
|
-
*
|
|
16
|
-
* @module services/smtpMailer
|
|
17
|
-
*/
|
|
18
|
-
import nodemailer from "nodemailer";
|
|
19
|
-
import type { AttachmentInput } from "../types.js";
|
|
20
|
-
/** Options for an SMTP send, mirroring the AppleScript send-email surface. */
|
|
21
|
-
export interface SmtpSendOptions {
|
|
22
|
-
to: string[];
|
|
23
|
-
subject: string;
|
|
24
|
-
/** Plain-text body. Always sent as the text/plain part. */
|
|
25
|
-
body: string;
|
|
26
|
-
cc?: string[];
|
|
27
|
-
bcc?: string[];
|
|
28
|
-
/** Overrides the configured From address (must be allowed by the SMTP server). */
|
|
29
|
-
from?: string;
|
|
30
|
-
/** Files to attach: absolute paths and/or inline base64 content (B4). */
|
|
31
|
-
attachments?: AttachmentInput[];
|
|
32
|
-
/**
|
|
33
|
-
* Optional HTML body. When provided, the message is sent as
|
|
34
|
-
* multipart/alternative ({@link SmtpSendOptions.body} as the text/plain part,
|
|
35
|
-
* this as the text/html part) so clients pick the richer rendering while
|
|
36
|
-
* plain-text clients still get a clean fallback.
|
|
37
|
-
*/
|
|
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[];
|
|
50
|
-
}
|
|
51
|
-
/** Resolved SMTP connection configuration. */
|
|
52
|
-
export interface SmtpConfig {
|
|
53
|
-
host: string;
|
|
54
|
-
port: number;
|
|
55
|
-
secure: boolean;
|
|
56
|
-
user: string;
|
|
57
|
-
pass: string;
|
|
58
|
-
from: string;
|
|
59
|
-
}
|
|
60
|
-
/** Result of an SMTP send. */
|
|
61
|
-
export interface SmtpSendResult {
|
|
62
|
-
success: boolean;
|
|
63
|
-
messageId?: string;
|
|
64
|
-
error?: string;
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Environment variables consumed by {@link resolveSmtpConfig}. Documented here
|
|
68
|
-
* (and in the README) so the error path can point users at exactly what to set.
|
|
69
|
-
*/
|
|
70
|
-
export declare const SMTP_ENV: {
|
|
71
|
-
readonly host: "APPLE_MAIL_MCP_SMTP_HOST";
|
|
72
|
-
readonly port: "APPLE_MAIL_MCP_SMTP_PORT";
|
|
73
|
-
readonly secure: "APPLE_MAIL_MCP_SMTP_SECURE";
|
|
74
|
-
readonly user: "APPLE_MAIL_MCP_SMTP_USER";
|
|
75
|
-
readonly from: "APPLE_MAIL_MCP_SMTP_FROM";
|
|
76
|
-
readonly password: "APPLE_MAIL_MCP_SMTP_PASSWORD";
|
|
77
|
-
readonly keychainService: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE";
|
|
78
|
-
readonly keychainAccount: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT";
|
|
79
|
-
};
|
|
80
|
-
/**
|
|
81
|
-
* Cheap check for whether the SMTP transport is configured at all, i.e. the two
|
|
82
|
-
* required settings ({@link SMTP_ENV.host} and {@link SMTP_ENV.user}) are
|
|
83
|
-
* present. Used to auto-prefer SMTP over AppleScript when no transport is
|
|
84
|
-
* explicitly requested, and by `doctor`. Does NOT touch the Keychain or verify
|
|
85
|
-
* the password — a misconfigured password still surfaces a clear error at send
|
|
86
|
-
* time via {@link resolveSmtpConfig}.
|
|
87
|
-
*/
|
|
88
|
-
export declare function isSmtpConfigured(env?: NodeJS.ProcessEnv): boolean;
|
|
89
|
-
/**
|
|
90
|
-
* Decides whether `send-email` should use the SMTP transport for this call.
|
|
91
|
-
*
|
|
92
|
-
* - explicit `"smtp"` → always SMTP (config errors surface, no fallback);
|
|
93
|
-
* - explicit `"applescript"` → always the Mail.app path;
|
|
94
|
-
* - omitted → SMTP when configured, **except** when a non-email `account` label
|
|
95
|
-
* (a Mail.app account name such as `"Work"`) is supplied. That requests
|
|
96
|
-
* account-based sending, which only the AppleScript path can do, so we honor
|
|
97
|
-
* the caller's intent instead of silently switching their account. An
|
|
98
|
-
* `account` that is an email address is treated as a From override and does
|
|
99
|
-
* not block SMTP.
|
|
100
|
-
*/
|
|
101
|
-
export declare function shouldUseSmtp(transport: "applescript" | "smtp" | undefined, account: string | undefined, configured?: boolean): boolean;
|
|
102
|
-
/**
|
|
103
|
-
* Reads a password from the macOS login Keychain via the `security` CLI.
|
|
104
|
-
*
|
|
105
|
-
* Tries `find-internet-password` first (where Mail.app stores account
|
|
106
|
-
* passwords) and falls back to `find-generic-password`. Returns null if no
|
|
107
|
-
* matching item exists or the lookup fails for any reason — callers fall back
|
|
108
|
-
* to the password env var and ultimately surface a clear configuration error.
|
|
109
|
-
*
|
|
110
|
-
* @param service - Keychain service / server name (typically the SMTP host)
|
|
111
|
-
* @param account - Keychain account (typically the SMTP username)
|
|
112
|
-
*/
|
|
113
|
-
export declare function readKeychainPassword(service: string, account: string): string | null;
|
|
114
|
-
/**
|
|
115
|
-
* Resolves SMTP connection configuration from environment + Keychain.
|
|
116
|
-
*
|
|
117
|
-
* @throws Error with an actionable message listing the missing settings.
|
|
118
|
-
*/
|
|
119
|
-
export declare function resolveSmtpConfig(env?: NodeJS.ProcessEnv): SmtpConfig;
|
|
120
|
-
/**
|
|
121
|
-
* Sends an email over SMTP, producing clean MIME with no blockquote wrapping.
|
|
122
|
-
*
|
|
123
|
-
* Config is resolved via {@link resolveSmtpConfig} unless one is injected (the
|
|
124
|
-
* `config` parameter exists for testing). The body is sent as plain text; pass
|
|
125
|
-
* a transporter factory only in tests.
|
|
126
|
-
*/
|
|
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[]>;
|
|
160
|
-
//# sourceMappingURL=smtpMailer.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"smtpMailer.d.ts","sourceRoot":"","sources":["../../src/services/smtpMailer.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SMTP transport for sending mail (issue #12).
|
|
3
|
-
*
|
|
4
|
-
* Mail.app's AppleScript send path wraps any injected body in
|
|
5
|
-
* `<blockquote type="cite">` under the Apple-Mail-URLShareWrapperClass template
|
|
6
|
-
* on macOS 15+, so messages render to recipients as quoted/forwarded content
|
|
7
|
-
* (Apple radar FB11734014, open since Ventura). This module bypasses Mail.app
|
|
8
|
-
* entirely and submits clean MIME directly over SMTP via nodemailer.
|
|
9
|
-
*
|
|
10
|
-
* Connection settings come from environment variables; the password is read
|
|
11
|
-
* from the macOS Keychain via the `security` CLI by default so no secret is
|
|
12
|
-
* ever placed in config. When SMTP is configured, `send-email` auto-prefers it
|
|
13
|
-
* over AppleScript (opt out per call with `transport: "applescript"`); see
|
|
14
|
-
* {@link shouldUseSmtp}.
|
|
15
|
-
*
|
|
16
|
-
* @module services/smtpMailer
|
|
17
|
-
*/
|
|
18
|
-
import nodemailer from "nodemailer";
|
|
19
|
-
import { execFileSync } from "child_process";
|
|
20
|
-
import { isAbsolute } from "path";
|
|
21
|
-
import { existsSync } from "fs";
|
|
22
|
-
/**
|
|
23
|
-
* Environment variables consumed by {@link resolveSmtpConfig}. Documented here
|
|
24
|
-
* (and in the README) so the error path can point users at exactly what to set.
|
|
25
|
-
*/
|
|
26
|
-
export const SMTP_ENV = {
|
|
27
|
-
host: "APPLE_MAIL_MCP_SMTP_HOST",
|
|
28
|
-
port: "APPLE_MAIL_MCP_SMTP_PORT",
|
|
29
|
-
secure: "APPLE_MAIL_MCP_SMTP_SECURE",
|
|
30
|
-
user: "APPLE_MAIL_MCP_SMTP_USER",
|
|
31
|
-
from: "APPLE_MAIL_MCP_SMTP_FROM",
|
|
32
|
-
password: "APPLE_MAIL_MCP_SMTP_PASSWORD",
|
|
33
|
-
keychainService: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_SERVICE",
|
|
34
|
-
keychainAccount: "APPLE_MAIL_MCP_SMTP_KEYCHAIN_ACCOUNT",
|
|
35
|
-
};
|
|
36
|
-
/**
|
|
37
|
-
* Cheap check for whether the SMTP transport is configured at all, i.e. the two
|
|
38
|
-
* required settings ({@link SMTP_ENV.host} and {@link SMTP_ENV.user}) are
|
|
39
|
-
* present. Used to auto-prefer SMTP over AppleScript when no transport is
|
|
40
|
-
* explicitly requested, and by `doctor`. Does NOT touch the Keychain or verify
|
|
41
|
-
* the password — a misconfigured password still surfaces a clear error at send
|
|
42
|
-
* time via {@link resolveSmtpConfig}.
|
|
43
|
-
*/
|
|
44
|
-
export function isSmtpConfigured(env = process.env) {
|
|
45
|
-
return Boolean(env[SMTP_ENV.host]?.trim() && env[SMTP_ENV.user]?.trim());
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Decides whether `send-email` should use the SMTP transport for this call.
|
|
49
|
-
*
|
|
50
|
-
* - explicit `"smtp"` → always SMTP (config errors surface, no fallback);
|
|
51
|
-
* - explicit `"applescript"` → always the Mail.app path;
|
|
52
|
-
* - omitted → SMTP when configured, **except** when a non-email `account` label
|
|
53
|
-
* (a Mail.app account name such as `"Work"`) is supplied. That requests
|
|
54
|
-
* account-based sending, which only the AppleScript path can do, so we honor
|
|
55
|
-
* the caller's intent instead of silently switching their account. An
|
|
56
|
-
* `account` that is an email address is treated as a From override and does
|
|
57
|
-
* not block SMTP.
|
|
58
|
-
*/
|
|
59
|
-
export function shouldUseSmtp(transport, account, configured = isSmtpConfigured()) {
|
|
60
|
-
if (transport === "smtp")
|
|
61
|
-
return true;
|
|
62
|
-
if (transport === "applescript")
|
|
63
|
-
return false;
|
|
64
|
-
if (!configured)
|
|
65
|
-
return false;
|
|
66
|
-
const isAccountLabel = Boolean(account && !account.includes("@"));
|
|
67
|
-
return !isAccountLabel;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Reads a password from the macOS login Keychain via the `security` CLI.
|
|
71
|
-
*
|
|
72
|
-
* Tries `find-internet-password` first (where Mail.app stores account
|
|
73
|
-
* passwords) and falls back to `find-generic-password`. Returns null if no
|
|
74
|
-
* matching item exists or the lookup fails for any reason — callers fall back
|
|
75
|
-
* to the password env var and ultimately surface a clear configuration error.
|
|
76
|
-
*
|
|
77
|
-
* @param service - Keychain service / server name (typically the SMTP host)
|
|
78
|
-
* @param account - Keychain account (typically the SMTP username)
|
|
79
|
-
*/
|
|
80
|
-
export function readKeychainPassword(service, account) {
|
|
81
|
-
for (const kind of ["find-internet-password", "find-generic-password"]) {
|
|
82
|
-
try {
|
|
83
|
-
const out = execFileSync("security", [kind, "-s", service, "-a", account, "-w"], {
|
|
84
|
-
encoding: "utf8",
|
|
85
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
86
|
-
});
|
|
87
|
-
const pass = out.replace(/\n$/, "");
|
|
88
|
-
if (pass)
|
|
89
|
-
return pass;
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
// Not found via this kind; try the next.
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Resolves SMTP connection configuration from environment + Keychain.
|
|
99
|
-
*
|
|
100
|
-
* @throws Error with an actionable message listing the missing settings.
|
|
101
|
-
*/
|
|
102
|
-
export function resolveSmtpConfig(env = process.env) {
|
|
103
|
-
const host = env[SMTP_ENV.host]?.trim();
|
|
104
|
-
const user = env[SMTP_ENV.user]?.trim();
|
|
105
|
-
const missing = [];
|
|
106
|
-
if (!host)
|
|
107
|
-
missing.push(SMTP_ENV.host);
|
|
108
|
-
if (!user)
|
|
109
|
-
missing.push(SMTP_ENV.user);
|
|
110
|
-
if (missing.length > 0) {
|
|
111
|
-
throw new Error(`SMTP transport is not configured. Set ${missing.join(" and ")} ` +
|
|
112
|
-
`(plus a password via ${SMTP_ENV.password} or the Keychain). ` +
|
|
113
|
-
`See the README "SMTP transport" section.`);
|
|
114
|
-
}
|
|
115
|
-
// secure=true => implicit TLS (port 465); otherwise STARTTLS (port 587).
|
|
116
|
-
const secure = /^(1|true|yes)$/i.test(env[SMTP_ENV.secure]?.trim() ?? "");
|
|
117
|
-
const port = env[SMTP_ENV.port]
|
|
118
|
-
? Number.parseInt(env[SMTP_ENV.port], 10)
|
|
119
|
-
: secure
|
|
120
|
-
? 465
|
|
121
|
-
: 587;
|
|
122
|
-
if (!Number.isInteger(port) || port <= 0) {
|
|
123
|
-
throw new Error(`Invalid ${SMTP_ENV.port}: "${env[SMTP_ENV.port]}" is not a valid port.`);
|
|
124
|
-
}
|
|
125
|
-
const from = env[SMTP_ENV.from]?.trim() || user;
|
|
126
|
-
// Password: explicit env var wins, otherwise Keychain (service/account
|
|
127
|
-
// default to the host/user but can be overridden).
|
|
128
|
-
let pass = env[SMTP_ENV.password];
|
|
129
|
-
if (!pass) {
|
|
130
|
-
const service = env[SMTP_ENV.keychainService]?.trim() || host;
|
|
131
|
-
const account = env[SMTP_ENV.keychainAccount]?.trim() || user;
|
|
132
|
-
pass = readKeychainPassword(service, account) ?? undefined;
|
|
133
|
-
}
|
|
134
|
-
if (!pass) {
|
|
135
|
-
throw new Error(`No SMTP password found. Set ${SMTP_ENV.password}, or store an internet ` +
|
|
136
|
-
`password in the Keychain for service "${env[SMTP_ENV.keychainService]?.trim() || host}" / account "${env[SMTP_ENV.keychainAccount]?.trim() || user}".`);
|
|
137
|
-
}
|
|
138
|
-
return { host: host, port, secure, user: user, pass, from };
|
|
139
|
-
}
|
|
140
|
-
/**
|
|
141
|
-
* Validates attachment paths the same way the AppleScript path does: absolute
|
|
142
|
-
* and existing. Returns nodemailer attachment descriptors.
|
|
143
|
-
*/
|
|
144
|
-
function buildAttachments(attachments) {
|
|
145
|
-
if (!attachments || attachments.length === 0)
|
|
146
|
-
return undefined;
|
|
147
|
-
return attachments.map((a) => {
|
|
148
|
-
if (typeof a === "string") {
|
|
149
|
-
if (!isAbsolute(a))
|
|
150
|
-
throw new Error(`Attachment path must be absolute: "${a}"`);
|
|
151
|
-
if (!existsSync(a))
|
|
152
|
-
throw new Error(`Attachment file not found: "${a}"`);
|
|
153
|
-
return { path: a };
|
|
154
|
-
}
|
|
155
|
-
if (!a.filename || !a.contentBase64) {
|
|
156
|
-
throw new Error("Inline attachment requires both filename and contentBase64.");
|
|
157
|
-
}
|
|
158
|
-
return { filename: a.filename, content: Buffer.from(a.contentBase64, "base64") };
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Sends an email over SMTP, producing clean MIME with no blockquote wrapping.
|
|
163
|
-
*
|
|
164
|
-
* Config is resolved via {@link resolveSmtpConfig} unless one is injected (the
|
|
165
|
-
* `config` parameter exists for testing). The body is sent as plain text; pass
|
|
166
|
-
* a transporter factory only in tests.
|
|
167
|
-
*/
|
|
168
|
-
export async function sendViaSmtp(opts, config, createTransport = nodemailer.createTransport) {
|
|
169
|
-
let cfg;
|
|
170
|
-
try {
|
|
171
|
-
cfg = config ?? resolveSmtpConfig();
|
|
172
|
-
}
|
|
173
|
-
catch (error) {
|
|
174
|
-
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
175
|
-
}
|
|
176
|
-
let attachments;
|
|
177
|
-
try {
|
|
178
|
-
attachments = buildAttachments(opts.attachments);
|
|
179
|
-
}
|
|
180
|
-
catch (error) {
|
|
181
|
-
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
182
|
-
}
|
|
183
|
-
const transporter = createTransport({
|
|
184
|
-
host: cfg.host,
|
|
185
|
-
port: cfg.port,
|
|
186
|
-
secure: cfg.secure,
|
|
187
|
-
auth: { user: cfg.user, pass: cfg.pass },
|
|
188
|
-
});
|
|
189
|
-
const html = opts.htmlBody?.trim() ? opts.htmlBody : undefined;
|
|
190
|
-
try {
|
|
191
|
-
const info = await transporter.sendMail({
|
|
192
|
-
from: opts.from?.trim() || cfg.from,
|
|
193
|
-
to: opts.to,
|
|
194
|
-
cc: opts.cc,
|
|
195
|
-
bcc: opts.bcc,
|
|
196
|
-
subject: opts.subject,
|
|
197
|
-
text: opts.body,
|
|
198
|
-
// When present, nodemailer emits multipart/alternative (text + html).
|
|
199
|
-
html,
|
|
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,
|
|
204
|
-
});
|
|
205
|
-
return { success: true, messageId: info.messageId };
|
|
206
|
-
}
|
|
207
|
-
catch (error) {
|
|
208
|
-
return {
|
|
209
|
-
success: false,
|
|
210
|
-
error: `SMTP send failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
finally {
|
|
214
|
-
transporter.close();
|
|
215
|
-
}
|
|
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
|
-
}
|