apple-mail-mcp 2.18.1 → 2.19.1

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 CHANGED
@@ -159,6 +159,7 @@ tool, and troubleshooting. Verify any time by running the **`doctor`** tool.
159
159
  | **List Messages** | List messages with pagination, sender filter, date display |
160
160
  | **Search Messages** | Search by sender, subject, content, date range, read/flagged status — across all accounts |
161
161
  | **Read Messages** | Get full email content (plain text or HTML) |
162
+ | **Read Headers** | Get a message's raw RFC 5322 headers — the author's `Date:`, Message-ID, threading ids, `Received:` trace — without downloading the body |
162
163
  | **Send Email** | Compose and send new emails (attach by file path or inline base64 content) |
163
164
  | **Send Serial Email** | Mail merge — send personalized emails to a list of recipients with {{placeholder}} support |
164
165
  | **Create Draft** | Save emails to Drafts folder (attach by file path or inline base64 content) |
@@ -288,7 +289,7 @@ Get the full content of a message.
288
289
  | `mailbox` | string | No | Mailbox holding the message (e.g. `"Sent Items"`). With `account`, opens that mailbox directly instead of scanning every mailbox — this is the fix for timeouts on large folders |
289
290
  | `account` | string | No | Account holding the message. Pair with `mailbox` to skip the cross-mailbox scan |
290
291
 
291
- **Returns:** Subject line and message body (plain text by default, HTML if `preferHtml` is true and HTML content is available).
292
+ **Returns:** Subject line and message body (plain text by default, HTML if `preferHtml` is true and HTML content is available). `structuredContent` also carries `rfcMessageId` and, since 2.19.0, two dates: `dateSent` (the message's `Date:` header — Mail's `date sent`) and `dateReceived` (arrival in the mailbox — Mail's `date received` / IMAP `INTERNALDATE`). They differ legitimately by transit time; when they differ by **years**, the mailbox was migrated or re-imported and the arrival timestamp was reset — trust `dateSent` for chronology ([#224](https://github.com/sweetrb/apple-mail-mcp/issues/224)).
292
293
 
293
294
  > **Large messages / attachments:** reading a full message routes through
294
295
  > `osascript`, whose captured output buffer defaults to **64 MB**. Override it
@@ -299,6 +300,22 @@ Get the full content of a message.
299
300
 
300
301
  ---
301
302
 
303
+ #### `get-message-headers`
304
+
305
+ Return a message's raw RFC 5322 header block — without fetching the body or any attachment — plus the parsed fields chronological and threading work needs. Added in 2.19.0 for mailboxes whose arrival timestamps were reset by a migration ([#224](https://github.com/sweetrb/apple-mail-mcp/issues/224)): the `Date:` header is the author's send time and survives such moves; `INTERNALDATE` / `date received` does not.
306
+
307
+ | Parameter | Type | Required | Description |
308
+ |-----------|------|----------|-------------|
309
+ | `id` | string | Yes | Message ID (numeric or `imap:…`) |
310
+ | `mailbox` | string | No | Mailbox holding the message. With `account`, opens that mailbox directly instead of scanning every mailbox |
311
+ | `account` | string | No | Account holding the message. Pair with `mailbox` to skip the cross-mailbox scan |
312
+
313
+ **Returns:** The raw header block as text. `structuredContent` carries `raw`, every header as ordered `headers[]` (`{name, value}`, folded lines joined, duplicates such as `Received:` kept in wire order, values left RFC 2047-encoded), `headerCount`, and the decoded key fields: `date` (ISO 8601, from the `Date:` header), `dateHeader` (verbatim), `dateReceived` (mailbox arrival time — IMAP `INTERNALDATE` or Mail's `date received`), `messageId`, `subject`, `from`, `to`, `cc`, `replyTo`, `inReplyTo`, `references[]` and `received[]` (first entry = last hop). Fields the message does not carry are omitted.
314
+
315
+ **Backends:** an `imap:` id fetches `BODY.PEEK[HEADER]` over IMAP (cheap even for a 20 MB message); a numeric id reads Mail's `all headers` property over AppleScript, with the same mailbox-scoped fast path as `get-message`.
316
+
317
+ ---
318
+
302
319
  #### `list-messages`
303
320
 
304
321
  List messages in a mailbox.
package/build/cli.js CHANGED
@@ -57771,6 +57771,18 @@ var init_mimeParse = __esm({
57771
57771
  }
57772
57772
  });
57773
57773
 
57774
+ // src/utils/headers.ts
57775
+ function isoOrUndefined(d) {
57776
+ if (d === void 0 || d === "") return void 0;
57777
+ const date = d instanceof Date ? d : new Date(d);
57778
+ return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
57779
+ }
57780
+ var init_headers = __esm({
57781
+ "src/utils/headers.ts"() {
57782
+ "use strict";
57783
+ }
57784
+ });
57785
+
57774
57786
  // src/services/auditLog.ts
57775
57787
  function classifyCountStatus(readable, expected, observed) {
57776
57788
  if (!readable) return { status: "unknown", unknownReason: "count-unreadable" };
@@ -57812,6 +57824,7 @@ __export(imapClient_exports, {
57812
57824
  imapFetchMessageId: () => imapFetchMessageId,
57813
57825
  imapFlagMessage: () => imapFlagMessage,
57814
57826
  imapGetMessage: () => imapGetMessage,
57827
+ imapGetMessageHeaders: () => imapGetMessageHeaders,
57815
57828
  imapGetMessageSource: () => imapGetMessageSource,
57816
57829
  imapHealthCheck: () => imapHealthCheck,
57817
57830
  imapListAttachments: () => imapListAttachments,
@@ -58604,7 +58617,10 @@ async function imapGetMessage(id, preferHtml, deps = {}) {
58604
58617
  return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
58605
58618
  const msg = await client.fetchOne(
58606
58619
  String(ref.uid),
58607
- { envelope: true, source: true },
58620
+ // INTERNALDATE rides along so the read can report the server's arrival
58621
+ // time beside the author's `Date:` header (#224). The envelope's `date`
58622
+ // IS the `Date:` header — imapflow builds ENVELOPE from the header block.
58623
+ { envelope: true, internalDate: true, source: true },
58608
58624
  { uid: true }
58609
58625
  );
58610
58626
  if (!msg)
@@ -58612,9 +58628,39 @@ async function imapGetMessage(id, preferHtml, deps = {}) {
58612
58628
  const subject = msg.envelope?.subject || "(no subject)";
58613
58629
  const src = msg.source ? msg.source.toString() : "";
58614
58630
  const body = (preferHtml ? extractHtmlBody(src) : extractTextBody(src)) ?? extractTextBody(src) ?? extractHtmlBody(src) ?? "(no readable body)";
58615
- return { success: true, info: `Subject: ${subject}
58631
+ return {
58632
+ success: true,
58633
+ info: `Subject: ${subject}
58616
58634
 
58617
- ${body}` };
58635
+ ${body}`,
58636
+ meta: {
58637
+ dateSent: isoOrUndefined(msg.envelope?.date),
58638
+ dateReceived: isoOrUndefined(msg.internalDate),
58639
+ // The envelope carries the Message-ID; `info` deliberately does not (it
58640
+ // is subject + body), so the caller could never recover it from there.
58641
+ rfcMessageId: msg.envelope?.messageId ? normalizeMessageId(msg.envelope.messageId) : ""
58642
+ }
58643
+ };
58644
+ });
58645
+ }
58646
+ async function imapGetMessageHeaders(id, deps = {}) {
58647
+ const ref = decodeImapId(id);
58648
+ if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
58649
+ return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
58650
+ const msg = await client.fetchOne(
58651
+ String(ref.uid),
58652
+ { envelope: true, internalDate: true, headers: true },
58653
+ { uid: true }
58654
+ );
58655
+ if (!msg)
58656
+ return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
58657
+ const raw = msg.headers ? msg.headers.toString() : "";
58658
+ if (!raw.trim()) return { success: false, error: "IMAP returned no header block." };
58659
+ return {
58660
+ success: true,
58661
+ info: raw,
58662
+ meta: { dateReceived: isoOrUndefined(msg.internalDate) }
58663
+ };
58618
58664
  });
58619
58665
  }
58620
58666
  function normalizeMessageId(mid) {
@@ -59054,6 +59100,7 @@ var init_imapClient = __esm({
59054
59100
  init_smtpMailer();
59055
59101
  init_docsUrls();
59056
59102
  init_mimeParse();
59103
+ init_headers();
59057
59104
  init_auditLog();
59058
59105
  init_attachmentLimits();
59059
59106
  IMAP_ENV = {
package/build/index.js CHANGED
@@ -65092,6 +65092,122 @@ var require_imap_flow = __commonJS({
65092
65092
  }
65093
65093
  });
65094
65094
 
65095
+ // src/utils/headers.ts
65096
+ function decodeEncodedWords(value) {
65097
+ if (!value.includes("=?")) return value;
65098
+ const joined = value.replace(/(\?=)\s+(=\?)/g, "$1$2");
65099
+ return joined.replace(
65100
+ /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g,
65101
+ (whole, charset, enc, text) => {
65102
+ try {
65103
+ const bytes = enc.toUpperCase() === "B" ? Buffer.from(text, "base64") : Buffer.from(
65104
+ text.replace(/_/g, " ").replace(
65105
+ /=([0-9A-Fa-f]{2})/g,
65106
+ (_m, h) => String.fromCharCode(parseInt(h, 16))
65107
+ ),
65108
+ "latin1"
65109
+ );
65110
+ return new TextDecoder(normalizeCharset(charset)).decode(bytes);
65111
+ } catch {
65112
+ return whole;
65113
+ }
65114
+ }
65115
+ );
65116
+ }
65117
+ function normalizeCharset(charset) {
65118
+ const bare = charset.split("*")[0].trim().toLowerCase();
65119
+ try {
65120
+ new TextDecoder(bare);
65121
+ return bare;
65122
+ } catch {
65123
+ return "utf-8";
65124
+ }
65125
+ }
65126
+ function bareId(raw) {
65127
+ return raw.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
65128
+ }
65129
+ function splitIds(raw) {
65130
+ const bracketed = raw.match(/<[^>]+>/g);
65131
+ if (bracketed) return bracketed.map(bareId).filter(Boolean);
65132
+ return raw.split(/[\s,]+/).map(bareId).filter(Boolean);
65133
+ }
65134
+ function parseHeaderBlock(input) {
65135
+ const text = (input ?? "").replace(/\r\n|\r/g, "\n");
65136
+ const blank = text.search(/\n\n/);
65137
+ const raw = (blank === -1 ? text : text.slice(0, blank)).replace(/\n+$/, "");
65138
+ const headers = [];
65139
+ for (const line of raw.split("\n")) {
65140
+ if (/^[ \t]/.test(line) && headers.length) {
65141
+ headers[headers.length - 1].value += " " + line.trim();
65142
+ continue;
65143
+ }
65144
+ const m = /^([!-9;-~]+):[ \t]?(.*)$/.exec(line);
65145
+ if (!m) continue;
65146
+ headers.push({ name: m[1], value: m[2].trim() });
65147
+ }
65148
+ const first = (name) => headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value;
65149
+ const all = (name) => headers.filter((h) => h.name.toLowerCase() === name.toLowerCase()).map((h) => h.value);
65150
+ const decoded = (name) => {
65151
+ const v = first(name);
65152
+ return v === void 0 ? void 0 : decodeEncodedWords(v);
65153
+ };
65154
+ const dateHeader = first("Date");
65155
+ let date3;
65156
+ if (dateHeader) {
65157
+ const parsed = new Date(dateHeader.replace(/\s*\([^)]*\)\s*$/, ""));
65158
+ if (!Number.isNaN(parsed.getTime())) date3 = parsed.toISOString();
65159
+ }
65160
+ const messageIdRaw = first("Message-ID") ?? first("Message-Id");
65161
+ const inReplyToRaw = first("In-Reply-To");
65162
+ const referencesRaw = first("References");
65163
+ return {
65164
+ raw,
65165
+ headers,
65166
+ date: date3,
65167
+ dateHeader,
65168
+ messageId: messageIdRaw ? bareId(messageIdRaw) || void 0 : void 0,
65169
+ subject: decoded("Subject"),
65170
+ from: decoded("From"),
65171
+ to: decoded("To"),
65172
+ cc: decoded("Cc"),
65173
+ replyTo: decoded("Reply-To"),
65174
+ inReplyTo: inReplyToRaw ? bareId(inReplyToRaw) || void 0 : void 0,
65175
+ references: referencesRaw ? splitIds(referencesRaw) : [],
65176
+ received: all("Received")
65177
+ };
65178
+ }
65179
+ function headersStructured(id, parsed, dateReceived) {
65180
+ const received = dateReceived instanceof Date ? Number.isNaN(dateReceived.getTime()) ? void 0 : dateReceived.toISOString() : dateReceived || void 0;
65181
+ return {
65182
+ id,
65183
+ raw: parsed.raw,
65184
+ headers: parsed.headers,
65185
+ headerCount: parsed.headers.length,
65186
+ ...parsed.date !== void 0 ? { date: parsed.date } : {},
65187
+ ...parsed.dateHeader !== void 0 ? { dateHeader: parsed.dateHeader } : {},
65188
+ ...received !== void 0 ? { dateReceived: received } : {},
65189
+ ...parsed.messageId !== void 0 ? { messageId: parsed.messageId } : {},
65190
+ ...parsed.subject !== void 0 ? { subject: parsed.subject } : {},
65191
+ ...parsed.from !== void 0 ? { from: parsed.from } : {},
65192
+ ...parsed.to !== void 0 ? { to: parsed.to } : {},
65193
+ ...parsed.cc !== void 0 ? { cc: parsed.cc } : {},
65194
+ ...parsed.replyTo !== void 0 ? { replyTo: parsed.replyTo } : {},
65195
+ ...parsed.inReplyTo !== void 0 ? { inReplyTo: parsed.inReplyTo } : {},
65196
+ references: parsed.references,
65197
+ received: parsed.received
65198
+ };
65199
+ }
65200
+ function isoOrUndefined(d) {
65201
+ if (d === void 0 || d === "") return void 0;
65202
+ const date3 = d instanceof Date ? d : new Date(d);
65203
+ return Number.isNaN(date3.getTime()) ? void 0 : date3.toISOString();
65204
+ }
65205
+ var init_headers = __esm({
65206
+ "src/utils/headers.ts"() {
65207
+ "use strict";
65208
+ }
65209
+ });
65210
+
65095
65211
  // src/services/imapClient.ts
65096
65212
  var imapClient_exports = {};
65097
65213
  __export(imapClient_exports, {
@@ -65118,6 +65234,7 @@ __export(imapClient_exports, {
65118
65234
  imapFetchMessageId: () => imapFetchMessageId,
65119
65235
  imapFlagMessage: () => imapFlagMessage,
65120
65236
  imapGetMessage: () => imapGetMessage,
65237
+ imapGetMessageHeaders: () => imapGetMessageHeaders,
65121
65238
  imapGetMessageSource: () => imapGetMessageSource,
65122
65239
  imapHealthCheck: () => imapHealthCheck,
65123
65240
  imapListAttachments: () => imapListAttachments,
@@ -65910,7 +66027,10 @@ async function imapGetMessage(id, preferHtml, deps = {}) {
65910
66027
  return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
65911
66028
  const msg = await client.fetchOne(
65912
66029
  String(ref.uid),
65913
- { envelope: true, source: true },
66030
+ // INTERNALDATE rides along so the read can report the server's arrival
66031
+ // time beside the author's `Date:` header (#224). The envelope's `date`
66032
+ // IS the `Date:` header — imapflow builds ENVELOPE from the header block.
66033
+ { envelope: true, internalDate: true, source: true },
65914
66034
  { uid: true }
65915
66035
  );
65916
66036
  if (!msg)
@@ -65918,9 +66038,39 @@ async function imapGetMessage(id, preferHtml, deps = {}) {
65918
66038
  const subject = msg.envelope?.subject || "(no subject)";
65919
66039
  const src = msg.source ? msg.source.toString() : "";
65920
66040
  const body = (preferHtml ? extractHtmlBody(src) : extractTextBody(src)) ?? extractTextBody(src) ?? extractHtmlBody(src) ?? "(no readable body)";
65921
- return { success: true, info: `Subject: ${subject}
66041
+ return {
66042
+ success: true,
66043
+ info: `Subject: ${subject}
65922
66044
 
65923
- ${body}` };
66045
+ ${body}`,
66046
+ meta: {
66047
+ dateSent: isoOrUndefined(msg.envelope?.date),
66048
+ dateReceived: isoOrUndefined(msg.internalDate),
66049
+ // The envelope carries the Message-ID; `info` deliberately does not (it
66050
+ // is subject + body), so the caller could never recover it from there.
66051
+ rfcMessageId: msg.envelope?.messageId ? normalizeMessageId(msg.envelope.messageId) : ""
66052
+ }
66053
+ };
66054
+ });
66055
+ }
66056
+ async function imapGetMessageHeaders(id, deps = {}) {
66057
+ const ref = decodeImapId(id);
66058
+ if (!ref) return { success: false, error: `Not an IMAP message id: "${id}".` };
66059
+ return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
66060
+ const msg = await client.fetchOne(
66061
+ String(ref.uid),
66062
+ { envelope: true, internalDate: true, headers: true },
66063
+ { uid: true }
66064
+ );
66065
+ if (!msg)
66066
+ return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
66067
+ const raw = msg.headers ? msg.headers.toString() : "";
66068
+ if (!raw.trim()) return { success: false, error: "IMAP returned no header block." };
66069
+ return {
66070
+ success: true,
66071
+ info: raw,
66072
+ meta: { dateReceived: isoOrUndefined(msg.internalDate) }
66073
+ };
65924
66074
  });
65925
66075
  }
65926
66076
  function normalizeMessageId(mid) {
@@ -66360,6 +66510,7 @@ var init_imapClient = __esm({
66360
66510
  init_smtpMailer();
66361
66511
  init_docsUrls();
66362
66512
  init_mimeParse();
66513
+ init_headers();
66363
66514
  init_auditLog();
66364
66515
  init_attachmentLimits();
66365
66516
  IMAP_ENV = {
@@ -81688,6 +81839,7 @@ var DIAG_FIELD_SEP = "F";
81688
81839
  var DIAG_ITEM_SEP = "M";
81689
81840
  var CONTENT_MARKER = "CONTENT";
81690
81841
  var MSGID_MARKER = "MSGID";
81842
+ var DATES_MARKER = "DATES";
81691
81843
  var HTML_MARKER = "HTML";
81692
81844
  var LOOKUP_ERROR_MARKER = "ERR";
81693
81845
  var BATCH_FATAL = "FATAL";
@@ -81819,6 +81971,26 @@ function buildAttachmentCommands(attachments) {
81819
81971
  return commands;
81820
81972
  }
81821
81973
  var AS_DATE_TO_STRING = `((year of d) as string) & "-" & ((month of d as integer) as string) & "-" & ((day of d) as string) & "-" & ((hours of d) as string) & "-" & ((minutes of d) as string) & "-" & ((seconds of d) as string)`;
81974
+ var AS_MESSAGE_DATES_FRAGMENT = `set msgDateSent to ""
81975
+ try
81976
+ set d to date sent of msg
81977
+ set msgDateSent to ${AS_DATE_TO_STRING}
81978
+ end try
81979
+ set msgDateRecv to ""
81980
+ try
81981
+ set d to date received of msg
81982
+ set msgDateRecv to ${AS_DATE_TO_STRING}
81983
+ end try
81984
+ set msgDates to msgDateSent & "|" & msgDateRecv`;
81985
+ function parseMessageDates(pair) {
81986
+ const [sent = "", received = ""] = pair.split("|");
81987
+ const parse3 = (v) => {
81988
+ if (!v.trim()) return void 0;
81989
+ const d = parseAppleScriptDate(v.trim());
81990
+ return Number.isNaN(d.getTime()) ? void 0 : d;
81991
+ };
81992
+ return { dateSent: parse3(sent), dateReceived: parse3(received) };
81993
+ }
81822
81994
  function buildMessageRowLoop(opts) {
81823
81995
  const { collection, limit, dedup, dateFilter, trailing = "", offset, withAttachments } = opts;
81824
81996
  const dedupOpen = dedup ? `if seenIds does not contain msgId then
@@ -83305,6 +83477,55 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
83305
83477
  hasAttachments: parts.length > 9 ? parts[9] === "true" : false
83306
83478
  };
83307
83479
  }
83480
+ /**
83481
+ * The unscoped by-id resolution: walk every mailbox of every account, then the
83482
+ * local store (#183), and run `innerAction` with `msg` bound when EXACTLY one
83483
+ * mailbox holds the id — `{LOOKUP_ERROR_MARKER}` prefixes both the not-found
83484
+ * and the ambiguous outcome. Shared by getMessageContent, getRawSource and
83485
+ * getMessageHeaders so the three reads cannot drift apart (the pre-#224 code
83486
+ * carried two byte-identical copies).
83487
+ */
83488
+ unscopedByIdScript(id, innerAction) {
83489
+ return buildAppLevelScript(`
83490
+ try
83491
+ set _hits to {}
83492
+ set _names to ""
83493
+ repeat with acct in accounts
83494
+ repeat with mb in mailboxes of acct
83495
+ try
83496
+ set matchingMsgs to (messages of mb whose id is ${Number(id)})
83497
+ if (count of matchingMsgs) > 0 then
83498
+ set end of _hits to item 1 of matchingMsgs
83499
+ set _names to _names & (name of acct) & "/" & (name of mb) & ", "
83500
+ end if
83501
+ end try
83502
+ end repeat
83503
+ end repeat
83504
+ -- #183: local mailboxes belong to no account, so the walk above cannot
83505
+ -- reach them. Collect into the SAME _hits/_names, which means an id
83506
+ -- present both in an account and locally is now correctly reported as
83507
+ -- ambiguous rather than silently resolving to the account copy.${localMailboxBindingFragment()}
83508
+ repeat with mb in _mbs
83509
+ try
83510
+ set matchingMsgs to (messages of mb whose id is ${Number(id)})
83511
+ if (count of matchingMsgs) > 0 then
83512
+ set end of _hits to item 1 of matchingMsgs
83513
+ set _names to _names & "${LOCAL_STORE_LABEL}/" & (name of mb) & ", "
83514
+ end if
83515
+ end try
83516
+ end repeat
83517
+ if (count of _hits) is 0 then return "${LOOKUP_ERROR_MARKER}Message not found"
83518
+ if (count of _hits) > 1 then return "${LOOKUP_ERROR_MARKER}${AMBIGUOUS_ID_PREFIX}${Number(id)} is present in more than one mailbox (" & _names & "); list or search that mailbox first so the read targets the right copy"
83519
+ if (count of _hits) is 1 then
83520
+ set msg to item 1 of _hits
83521
+ ${innerAction}
83522
+ end if
83523
+ return ""
83524
+ on error errMsg
83525
+ return ""
83526
+ end try
83527
+ `);
83528
+ }
83308
83529
  /**
83309
83530
  * Build an app-level AppleScript that opens exactly one account+mailbox, finds
83310
83531
  * the message with numeric `id` in it, and runs `innerAction` (which may assume
@@ -83384,7 +83605,8 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
83384
83605
  end try
83385
83606
  set msgContent to content of msg
83386
83607
  ${sourceFetch}
83387
- return msgSubject & "${MSGID_MARKER}" & msgRfcId & "${CONTENT_MARKER}" & msgContent & "${HTML_MARKER}" & htmlSource`;
83608
+ ${AS_MESSAGE_DATES_FRAGMENT}
83609
+ return msgSubject & "${MSGID_MARKER}" & msgRfcId & "${DATES_MARKER}" & msgDates & "${CONTENT_MARKER}" & msgContent & "${HTML_MARKER}" & htmlSource`;
83388
83610
  const loc = hint?.account && hint?.mailbox ? { account: hint.account, mailbox: hint.mailbox } : this.idLocationIndex.get(id.toString());
83389
83611
  if (loc) {
83390
83612
  const scopedScript = this.scopedByIdScript(loc.account, loc.mailbox, id, innerFetch);
@@ -83395,45 +83617,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
83395
83617
  );
83396
83618
  if (scoped) return scoped;
83397
83619
  }
83398
- const script = buildAppLevelScript(`
83399
- try
83400
- set _hits to {}
83401
- set _names to ""
83402
- repeat with acct in accounts
83403
- repeat with mb in mailboxes of acct
83404
- try
83405
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
83406
- if (count of matchingMsgs) > 0 then
83407
- set end of _hits to item 1 of matchingMsgs
83408
- set _names to _names & (name of acct) & "/" & (name of mb) & ", "
83409
- end if
83410
- end try
83411
- end repeat
83412
- end repeat
83413
- -- #183: local mailboxes belong to no account, so the walk above cannot
83414
- -- reach them. Collect into the SAME _hits/_names, which means an id
83415
- -- present both in an account and locally is now correctly reported as
83416
- -- ambiguous rather than silently resolving to the account copy.${localMailboxBindingFragment()}
83417
- repeat with mb in _mbs
83418
- try
83419
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
83420
- if (count of matchingMsgs) > 0 then
83421
- set end of _hits to item 1 of matchingMsgs
83422
- set _names to _names & "${LOCAL_STORE_LABEL}/" & (name of mb) & ", "
83423
- end if
83424
- end try
83425
- end repeat
83426
- if (count of _hits) is 0 then return "${LOOKUP_ERROR_MARKER}Message not found"
83427
- if (count of _hits) > 1 then return "${LOOKUP_ERROR_MARKER}${AMBIGUOUS_ID_PREFIX}${Number(id)} is present in more than one mailbox (" & _names & "); list or search that mailbox first so the read targets the right copy"
83428
- if (count of _hits) is 1 then
83429
- set msg to item 1 of _hits
83430
- ${innerFetch}
83431
- end if
83432
- return ""
83433
- on error errMsg
83434
- return ""
83435
- end try
83436
- `);
83620
+ const script = this.unscopedByIdScript(id, innerFetch);
83437
83621
  return this.parseMessageContent(
83438
83622
  id,
83439
83623
  executeAppleScript(script, { timeoutMs: 6e4 }),
@@ -83461,15 +83645,62 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
83461
83645
  if (parts.length < 2) return null;
83462
83646
  const subjParts = parts[0].split(MSGID_MARKER);
83463
83647
  const subject = subjParts[0];
83464
- const rfcMessageId = normalizeRfcMessageId(subjParts.length > 1 ? subjParts[1] : "");
83648
+ const idAndDates = (subjParts.length > 1 ? subjParts[1] : "").split(DATES_MARKER);
83649
+ const rfcMessageId = normalizeRfcMessageId(idAndDates[0]);
83650
+ const { dateSent, dateReceived } = parseMessageDates(idAndDates[1] ?? "");
83465
83651
  const htmlContent = includeHtml && rawSource ? extractHtmlBody(rawSource) || void 0 : void 0;
83466
83652
  return {
83467
83653
  id: id.toString(),
83468
83654
  subject,
83469
83655
  plainText: parts[1],
83470
83656
  htmlContent,
83471
- rfcMessageId
83657
+ rfcMessageId,
83658
+ ...dateSent ? { dateSent } : {},
83659
+ ...dateReceived ? { dateReceived } : {}
83660
+ };
83661
+ }
83662
+ /**
83663
+ * Fetch ONLY the raw RFC 5322 header block of a message (#224) — Mail's
83664
+ * `all headers` property — plus its `date received`, so the caller can show the
83665
+ * arrival timestamp beside the author's `Date:` header. Same scoped-fast-path /
83666
+ * full-scan resolution as getMessageContent; never reads the body or source.
83667
+ * Returns null (with `lastMessageLookupError` set when Mail said why) on a miss.
83668
+ */
83669
+ getMessageHeaders(id, hint) {
83670
+ this.lastMessageLookupError = void 0;
83671
+ const innerFetch = `
83672
+ set msgHeaders to ""
83673
+ try
83674
+ set msgHeaders to all headers of msg
83675
+ end try
83676
+ ${AS_MESSAGE_DATES_FRAGMENT}
83677
+ return msgDates & "${DATES_MARKER}" & msgHeaders`;
83678
+ const parse3 = (result) => {
83679
+ if (!result.success || !result.output.trim()) {
83680
+ if (!result.success) console.error(`Failed to get message headers: ${result.error}`);
83681
+ return null;
83682
+ }
83683
+ if (result.output.startsWith(LOOKUP_ERROR_MARKER)) {
83684
+ this.lastMessageLookupError = result.output.slice(LOOKUP_ERROR_MARKER.length).trim();
83685
+ return null;
83686
+ }
83687
+ const idx = result.output.indexOf(DATES_MARKER);
83688
+ if (idx === -1) return null;
83689
+ const { dateReceived } = parseMessageDates(result.output.slice(0, idx));
83690
+ const raw = result.output.slice(idx + DATES_MARKER.length);
83691
+ if (!raw.trim()) return null;
83692
+ return { raw, ...dateReceived ? { dateReceived } : {} };
83472
83693
  };
83694
+ const loc = hint?.account && hint?.mailbox ? { account: hint.account, mailbox: hint.mailbox } : this.idLocationIndex.get(id.toString());
83695
+ if (loc) {
83696
+ const scoped = parse3(
83697
+ executeAppleScript(this.scopedByIdScript(loc.account, loc.mailbox, id, innerFetch), {
83698
+ timeoutMs: 6e4
83699
+ })
83700
+ );
83701
+ if (scoped) return scoped;
83702
+ }
83703
+ return parse3(executeAppleScript(this.unscopedByIdScript(id, innerFetch), { timeoutMs: 6e4 }));
83473
83704
  }
83474
83705
  /**
83475
83706
  * Get the raw MIME source of a message.
@@ -83498,45 +83729,7 @@ ${indent}end try${this.sanitizeFragment("_uacct", indent)}${this.sanitizeFragmen
83498
83729
  this.lastMessageLookupError = scoped.output.slice(LOOKUP_ERROR_MARKER.length).trim();
83499
83730
  }
83500
83731
  }
83501
- const script = buildAppLevelScript(`
83502
- try
83503
- set _hits to {}
83504
- set _names to ""
83505
- repeat with acct in accounts
83506
- repeat with mb in mailboxes of acct
83507
- try
83508
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
83509
- if (count of matchingMsgs) > 0 then
83510
- set end of _hits to item 1 of matchingMsgs
83511
- set _names to _names & (name of acct) & "/" & (name of mb) & ", "
83512
- end if
83513
- end try
83514
- end repeat
83515
- end repeat
83516
- -- #183: local mailboxes belong to no account, so the walk above cannot
83517
- -- reach them. Collect into the SAME _hits/_names, which means an id
83518
- -- present both in an account and locally is now correctly reported as
83519
- -- ambiguous rather than silently resolving to the account copy.${localMailboxBindingFragment()}
83520
- repeat with mb in _mbs
83521
- try
83522
- set matchingMsgs to (messages of mb whose id is ${Number(id)})
83523
- if (count of matchingMsgs) > 0 then
83524
- set end of _hits to item 1 of matchingMsgs
83525
- set _names to _names & "${LOCAL_STORE_LABEL}/" & (name of mb) & ", "
83526
- end if
83527
- end try
83528
- end repeat
83529
- if (count of _hits) is 0 then return "${LOOKUP_ERROR_MARKER}Message not found"
83530
- if (count of _hits) > 1 then return "${LOOKUP_ERROR_MARKER}${AMBIGUOUS_ID_PREFIX}${Number(id)} is present in more than one mailbox (" & _names & "); list or search that mailbox first so the read targets the right copy"
83531
- if (count of _hits) is 1 then
83532
- set msg to item 1 of _hits
83533
- return source of msg
83534
- end if
83535
- return ""
83536
- on error errMsg
83537
- return ""
83538
- end try
83539
- `);
83732
+ const script = this.unscopedByIdScript(id, "return source of msg");
83540
83733
  const result = executeAppleScript(script, { timeoutMs: 12e4 });
83541
83734
  if (!result.success || !result.output.trim()) {
83542
83735
  return null;
@@ -87082,6 +87275,7 @@ function subjectFromGetMessage(info) {
87082
87275
 
87083
87276
  // src/index.ts
87084
87277
  init_mimeParse();
87278
+ init_headers();
87085
87279
 
87086
87280
  // src/services/imapIdle.ts
87087
87281
  var import_imapflow2 = __toESM(require_imap_flow(), 1);
@@ -87635,9 +87829,9 @@ registerTool(
87635
87829
  "get-message",
87636
87830
  {
87637
87831
  description: `Use when: reading the full body of one message whose id you already have (numeric or imap:\u2026); set preferHtml to get the HTML body instead of plain text.
87638
- Returns: the message subject, body (plain text by default, HTML when preferHtml is true), and its stable RFC Message-ID (rfcMessageId) for dedup/threading.
87832
+ Returns: the message subject, body (plain text by default, HTML when preferHtml is true), its stable RFC Message-ID (rfcMessageId) for dedup/threading, and two dates: dateSent (the author's Date: header \u2014 survives a migration/re-import) and dateReceived (arrival in the mailbox; this is the one a migration resets).
87639
87833
  Tip: pass the mailbox+account you got the id from (e.g. from search-messages) to fetch it directly \u2014 required for reliable reads of large folders like "Sent Items", which otherwise time out.
87640
- Do not use when: you don't yet have an id (use search-messages or list-messages first), or you want the whole conversation (use get-thread).`,
87834
+ Do not use when: you don't yet have an id (use search-messages or list-messages first), you want the whole conversation (use get-thread), or you need the raw headers / Received: trace (use get-message-headers).`,
87641
87835
  inputSchema: {
87642
87836
  id: MESSAGE_ID_SCHEMA,
87643
87837
  preferHtml: external_exports.boolean().optional().describe("Return the HTML body (extracted from the message source) instead of plain text"),
@@ -87655,6 +87849,12 @@ Do not use when: you don't yet have an id (use search-messages or list-messages
87655
87849
  isHtml: external_exports.boolean().optional(),
87656
87850
  rfcMessageId: external_exports.string().optional().describe(
87657
87851
  "Stable RFC 5322 Message-ID (angle brackets stripped); empty when the message has none"
87852
+ ),
87853
+ dateSent: external_exports.string().optional().describe(
87854
+ "ISO 8601 send time from the message's Date: header (Mail's `date sent`). Absent when the message carries no parseable Date: header."
87855
+ ),
87856
+ dateReceived: external_exports.string().optional().describe(
87857
+ "ISO 8601 arrival time in the mailbox (IMAP INTERNALDATE / Mail's `date received`). A migration or re-import resets this; compare with dateSent."
87658
87858
  )
87659
87859
  }
87660
87860
  },
@@ -87672,7 +87872,13 @@ Do not use when: you don't yet have an id (use search-messages or list-messages
87672
87872
  subject: subjectFromGetMessage(r.info),
87673
87873
  body: sep3 >= 0 ? r.info.slice(sep3 + 2) : r.info,
87674
87874
  isHtml: preferHtml === true,
87675
- rfcMessageId: extractRfcMessageIdFromSource(r.info)
87875
+ // Prefer the envelope's Message-ID (#224 fix): `info` is subject +
87876
+ // body with no header block, so parsing it yielded "" for every
87877
+ // IMAP-sourced message from 2.2.0 through 2.18.1.
87878
+ rfcMessageId: r.meta?.rfcMessageId || extractRfcMessageIdFromSource(r.info),
87879
+ // #224: envelope Date: header and INTERNALDATE, already ISO strings.
87880
+ ...r.meta?.dateSent ? { dateSent: r.meta.dateSent } : {},
87881
+ ...r.meta?.dateReceived ? { dateReceived: r.meta.dateReceived } : {}
87676
87882
  };
87677
87883
  },
87678
87884
  apple: () => {
@@ -87686,6 +87892,8 @@ Do not use when: you don't yet have an id (use search-messages or list-messages
87686
87892
  }
87687
87893
  const isHtml = preferHtml === true && !!content.htmlContent;
87688
87894
  const body = isHtml ? content.htmlContent : content.plainText;
87895
+ const dateSent = isoOrUndefined(content.dateSent);
87896
+ const dateReceived = isoOrUndefined(content.dateReceived);
87689
87897
  return successResponse(`Subject: ${content.subject}
87690
87898
 
87691
87899
  ${body}`, {
@@ -87693,7 +87901,9 @@ ${body}`, {
87693
87901
  subject: content.subject,
87694
87902
  body,
87695
87903
  isHtml,
87696
- rfcMessageId: content.rfcMessageId ?? ""
87904
+ rfcMessageId: content.rfcMessageId ?? "",
87905
+ ...dateSent ? { dateSent } : {},
87906
+ ...dateReceived ? { dateReceived } : {}
87697
87907
  });
87698
87908
  },
87699
87909
  ok: "",
@@ -87702,6 +87912,63 @@ ${body}`, {
87702
87912
  "Error retrieving message"
87703
87913
  )
87704
87914
  );
87915
+ var HEADER_FIELD_SCHEMA = external_exports.object({
87916
+ name: external_exports.string().describe("Header name as written (case preserved)"),
87917
+ value: external_exports.string().describe("Unfolded value; RFC 2047 encoded-words left as-is")
87918
+ });
87919
+ registerTool(
87920
+ "get-message-headers",
87921
+ {
87922
+ description: "Use when: you need a message's raw RFC 5322 headers \u2014 the author's Date: header (not the mailbox arrival time), Message-ID, In-Reply-To/References, the Received: hop trace, or any custom X- header \u2014 for a message whose id you already have (numeric or imap:\u2026). Cheap: never downloads the body or attachments.\nReturns: the raw header block (text), every header as ordered {name, value} pairs with folding undone, and the decoded key fields: date (ISO 8601, from the Date: header), dateHeader (verbatim), dateReceived (mailbox arrival time \u2014 the value a migration or re-import resets, so compare it with date), messageId, subject, from, to, cc, replyTo, inReplyTo, references[], received[].\nTip: pass the mailbox+account you got the id from so a numeric id is fetched directly instead of scanning every mailbox.\nDo not use when: you want the body (use get-message), the conversation (use get-thread), or only the Message-ID (get-message already returns rfcMessageId).",
87923
+ inputSchema: {
87924
+ id: MESSAGE_ID_SCHEMA,
87925
+ mailbox: external_exports.string().optional().describe(
87926
+ "Mailbox that holds the message (numeric ids are unique per mailbox). With `account`, opens that mailbox directly instead of scanning every mailbox."
87927
+ ),
87928
+ account: external_exports.string().optional().describe("Account that holds the message. Pair with `mailbox` for a direct fetch.")
87929
+ },
87930
+ outputSchema: {
87931
+ id: external_exports.string().optional(),
87932
+ raw: external_exports.string().optional().describe("The raw header block, exactly as stored"),
87933
+ headers: external_exports.array(HEADER_FIELD_SCHEMA).optional(),
87934
+ headerCount: external_exports.number().optional(),
87935
+ date: external_exports.string().optional().describe("ISO 8601 from the Date: header \u2014 the author's send time"),
87936
+ dateHeader: external_exports.string().optional().describe("The Date: header verbatim"),
87937
+ dateReceived: external_exports.string().optional().describe("ISO 8601 mailbox arrival time (INTERNALDATE / Mail's `date received`)"),
87938
+ messageId: external_exports.string().optional().describe("Bare RFC 5322 Message-ID"),
87939
+ subject: external_exports.string().optional().describe("RFC 2047-decoded Subject:"),
87940
+ from: external_exports.string().optional(),
87941
+ to: external_exports.string().optional(),
87942
+ cc: external_exports.string().optional(),
87943
+ replyTo: external_exports.string().optional(),
87944
+ inReplyTo: external_exports.string().optional(),
87945
+ references: external_exports.array(external_exports.string()).optional(),
87946
+ received: external_exports.array(external_exports.string()).optional().describe("Every Received: header, as written (first = last hop)")
87947
+ },
87948
+ annotations: { readOnlyHint: true }
87949
+ },
87950
+ withErrorHandling(
87951
+ ({ id, mailbox, account }) => routeMessage(id, {
87952
+ // imap: id → BODY.PEEK[HEADER] + INTERNALDATE, no body download.
87953
+ imap: () => imapGetMessageHeaders(id, { account }),
87954
+ structuredFromResult: (r) => r.info ? headersStructured(id, parseHeaderBlock(r.info), r.meta?.dateReceived) : void 0,
87955
+ apple: () => {
87956
+ const h = mailManager.getMessageHeaders(id, { account, mailbox });
87957
+ if (!h) {
87958
+ const lookupError = mailManager.consumeLastMessageLookupError();
87959
+ return errorResponse(lookupError ?? `Message with ID "${id}" not found`);
87960
+ }
87961
+ return successResponse(
87962
+ h.raw,
87963
+ headersStructured(id, parseHeaderBlock(h.raw), h.dateReceived)
87964
+ );
87965
+ },
87966
+ ok: "",
87967
+ fail: `Message with ID "${id}" not found`
87968
+ }),
87969
+ "Error retrieving message headers"
87970
+ )
87971
+ );
87705
87972
  registerTool(
87706
87973
  "get-thread",
87707
87974
  {
@@ -28,7 +28,7 @@ When an account is IMAP-configured, these route to IMAP (otherwise AppleScript):
28
28
  | Capability | Tools |
29
29
  |------------|-------|
30
30
  | Server-side search / list | `search-messages`, `list-messages` |
31
- | Read a message | `get-message` |
31
+ | Read a message | `get-message`, `get-message-headers` |
32
32
  | Message mutations | `mark-as-read`/`unread`, `flag`/`unflag-message`, `move-message`, `delete-message` |
33
33
  | Batch mutations | `batch-mark-as-read`/`unread`, `batch-flag`/`unflag-messages`, `batch-move-messages`, `batch-delete-messages` |
34
34
  | Folder ops | `create-mailbox`, `rename-mailbox`, `delete-mailbox` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-mail-mcp",
3
- "version": "2.18.1",
3
+ "version": "2.19.1",
4
4
  "description": "MCP server for Apple Mail - read, search, send, and manage emails via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",