apple-mail-mcp 2.19.5 → 2.19.7

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.
Files changed (4) hide show
  1. package/README.md +30 -12
  2. package/build/cli.js +314 -22
  3. package/build/index.js +414 -217
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -236,6 +236,19 @@ Search for messages matching criteria. Searches all accounts by default.
236
236
  | `dateTo` | string | No | End date filter (e.g., "March 1, 2026") |
237
237
  | `limit` | number | No | Max results, 1–500 (default: 50) |
238
238
 
239
+ **Returns:** List of matching messages with ID, date, subject, sender, and read state.
240
+
241
+ ⚠️ **Row dates.** Since 2.19.7 every row — on both the IMAP and AppleScript
242
+ backends — carries both `dateSent` (the message's `Date:` header) and
243
+ `dateReceived` (mailbox arrival — IMAP `INTERNALDATE` or Mail's `date received`).
244
+ They differ legitimately by transit time; when they differ by **years**, the
245
+ mailbox was migrated or re-imported and the arrival timestamp was reset — trust
246
+ `dateSent` for chronology ([#224](https://github.com/sweetrb/apple-mail-mcp/issues/224)).
247
+ `dateSent` is omitted (not invented) when the message carries no parseable
248
+ `Date:` header, or when it is more than 7 days later than `dateReceived` — Mail
249
+ substitutes a timestamp of its own for a `Date:` header it cannot parse
250
+ ([#234](https://github.com/sweetrb/apple-mail-mcp/issues/234)).
251
+
239
252
  **Large mailboxes & partial results.** Apple Mail's AppleScript bridge cannot
240
253
  search very large IMAP/Gmail mailboxes (tens of thousands of messages) before
241
254
  the Apple Event times out — empirically even reading the newest 20 messages of
@@ -289,7 +302,7 @@ Get the full content of a message.
289
302
  | `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 |
290
303
  | `account` | string | No | Account holding the message. Pair with `mailbox` to skip the cross-mailbox scan |
291
304
 
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)).
305
+ **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)). Since 2.19.6 `dateSent` is **omitted** when it is more than 7 days later than `dateReceived`: a message cannot be sent after it arrived, and Mail.app substitutes a timestamp of its own for a `Date:` header it cannot parse ([#234](https://github.com/sweetrb/apple-mail-mcp/issues/234)). `isHtml` reports what was actually returned — a message with no `text/plain` part returns its HTML part with `isHtml: true`. Bodies are decoded by each part's declared `charset`, falling back to windows-1252 for bytes that are not valid UTF-8.
293
306
 
294
307
  > **Large messages / attachments:** reading a full message routes through
295
308
  > `osascript`, whose captured output buffer defaults to **64 MB**. Override it
@@ -310,9 +323,9 @@ Return a message's raw RFC 5322 header block — without fetching the body or an
310
323
  | `mailbox` | string | No | Mailbox holding the message. With `account`, opens that mailbox directly instead of scanning every mailbox |
311
324
  | `account` | string | No | Account holding the message. Pair with `mailbox` to skip the cross-mailbox scan |
312
325
 
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.
326
+ **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. `backend` says which backend read the block (`"imap"` or `"applescript"`), and `warnings[]` appears when a malformed block was repaired ([#234](https://github.com/sweetrb/apple-mail-mcp/issues/234)).
314
327
 
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`.
328
+ **Backends:** an `imap:` id reads the first 64 KiB of `BODY.PEEK[]` over IMAP and cuts the header block out of it (cheap even for a 20 MB message). That is deliberate: iCloud rewrites 8-bit header bytes to `*` in ENVELOPE and `BODY[HEADER]`, and only `BODY[]` returns them as stored; each line is decoded as UTF-8, falling back to windows-1252, so a raw latin-1 display name survives. A header block larger than the window falls back to `BODY.PEEK[HEADER]`. A numeric id reads Mail's `all headers` property over AppleScript, with the same mailbox-scoped fast path as `get-message`. ⚠️ That property is Mail's own rendering, not the stored bytes: for a `Date:` value Mail cannot parse it drops the value and joins the next header onto the name (`Date: Subject: …`). The tool splits that back apart, reports the date as absent rather than as a Subject string, and says so in `warnings[]`; the `imap:` id for the same message has the real `Date:`.
316
329
 
317
330
  ---
318
331
 
@@ -331,15 +344,20 @@ List messages in a mailbox.
331
344
 
332
345
  **Returns:** List of messages with ID, date, subject, and sender.
333
346
 
334
- ⚠️ **Row dates differ by backend.** On the **IMAP** path each row carries both
335
- `dateSent` (the message's `Date:` header) and `dateReceived` (mailbox arrival —
336
- `INTERNALDATE`, falling back to the header date if the server withholds it).
337
- They differ legitimately by transit time; when they differ by **years**, the
338
- mailbox was migrated or re-imported and the arrival timestamp was reset — trust
339
- `dateSent` for chronology ([#224](https://github.com/sweetrb/apple-mail-mcp/issues/224)).
340
- On the **AppleScript** path rows carry only `dateReceived` (Mail's `date
341
- received`); `dateSent` is not yet emitted there. Sort order keys on the header
342
- date on both backends, which is the stable one.
347
+ ⚠️ **Row dates.** Since 2.19.7 every row — on both the **IMAP** and
348
+ **AppleScript** backends — carries both `dateSent` (the message's `Date:`
349
+ header) and `dateReceived` (mailbox arrival — IMAP `INTERNALDATE`, falling back
350
+ to the header date if the server withholds it; Mail's `date received` on the
351
+ AppleScript path). They differ legitimately by transit time; when they differ
352
+ by **years**, the mailbox was migrated or re-imported and the arrival timestamp
353
+ was reset — trust `dateSent` for chronology
354
+ ([#224](https://github.com/sweetrb/apple-mail-mcp/issues/224)). Since 2.19.6 the
355
+ IMAP `dateSent` is recovered even when the server's own parse of the `Date:`
356
+ header failed (legacy locale dates such as `jue ago 30 13:55:12 2007`); both
357
+ backends omit `dateSent` (rather than reporting it) when it is more than 7 days
358
+ later than `dateReceived` ([#234](https://github.com/sweetrb/apple-mail-mcp/issues/234)).
359
+ Before 2.19.7 the AppleScript path carried only `dateReceived`. Sort order keys
360
+ on the header date on both backends, which is the stable one.
343
361
 
344
362
  ---
345
363
 
package/build/cli.js CHANGED
@@ -57664,6 +57664,24 @@ function extractMimeType(headers) {
57664
57664
  const typeMatch = ctHeader.match(/^([^;\s]+)/);
57665
57665
  return typeMatch ? typeMatch[1].toLowerCase() : "application/octet-stream";
57666
57666
  }
57667
+ function extractCharset(headers) {
57668
+ const ct = getHeader(headers, "Content-Type");
57669
+ const m = ct?.match(/charset\s*=\s*"?([^";\s]+)"?/i);
57670
+ return m ? m[1].toLowerCase() : null;
57671
+ }
57672
+ function decodePartText(bytes, charset) {
57673
+ if (charset && !["utf-8", "utf8", "us-ascii", "ascii"].includes(charset)) {
57674
+ try {
57675
+ return new TextDecoder(charset).decode(bytes);
57676
+ } catch {
57677
+ }
57678
+ }
57679
+ try {
57680
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
57681
+ } catch {
57682
+ return new TextDecoder("windows-1252").decode(bytes);
57683
+ }
57684
+ }
57667
57685
  function splitMimeParts(source, boundary) {
57668
57686
  const parts = [];
57669
57687
  const boundaryDelim = `--${boundary}`;
@@ -57729,7 +57747,7 @@ function extractHtmlBody(source) {
57729
57747
  for (const part of walkLeafParts(source, boundary)) {
57730
57748
  if (extractMimeType(part.headers) === "text/html") {
57731
57749
  const encoding2 = getHeader(part.headers, "Content-Transfer-Encoding");
57732
- return decodeBody(part.body, encoding2).toString("utf8");
57750
+ return decodePartText(decodeBody(part.body, encoding2), extractCharset(part.headers));
57733
57751
  }
57734
57752
  }
57735
57753
  return null;
@@ -57740,7 +57758,7 @@ function extractHtmlBody(source) {
57740
57758
  if (extractMimeType(headers) !== "text/html") return null;
57741
57759
  const body = source.substring(blankLineIdx).replace(/^\r?\n\r?\n/, "");
57742
57760
  const encoding = getHeader(headers, "Content-Transfer-Encoding");
57743
- return decodeBody(body, encoding).toString("utf8");
57761
+ return decodePartText(decodeBody(body, encoding), extractCharset(headers));
57744
57762
  }
57745
57763
  function extractTextBody(source) {
57746
57764
  if (!source || !source.trim()) return null;
@@ -57749,7 +57767,7 @@ function extractTextBody(source) {
57749
57767
  for (const part of walkLeafParts(source, boundary)) {
57750
57768
  if (extractMimeType(part.headers) === "text/plain") {
57751
57769
  const encoding2 = getHeader(part.headers, "Content-Transfer-Encoding");
57752
- return decodeBody(part.body, encoding2).toString("utf8");
57770
+ return decodePartText(decodeBody(part.body, encoding2), extractCharset(part.headers));
57753
57771
  }
57754
57772
  }
57755
57773
  return null;
@@ -57761,7 +57779,7 @@ function extractTextBody(source) {
57761
57779
  if (ct !== "text/plain" && getHeader(headers, "Content-Type") !== null) return null;
57762
57780
  const body = source.substring(blankLineIdx).replace(/^\r?\n\r?\n/, "");
57763
57781
  const encoding = getHeader(headers, "Content-Transfer-Encoding");
57764
- return decodeBody(body, encoding).toString("utf8");
57782
+ return decodePartText(decodeBody(body, encoding), extractCharset(headers));
57765
57783
  }
57766
57784
  var MAX_MIME_DEPTH;
57767
57785
  var init_mimeParse = __esm({
@@ -57772,14 +57790,225 @@ var init_mimeParse = __esm({
57772
57790
  });
57773
57791
 
57774
57792
  // src/utils/headers.ts
57793
+ function decodeEncodedWords(value) {
57794
+ if (!value.includes("=?")) return value;
57795
+ const joined = value.replace(/(\?=)\s+(=\?)/g, "$1$2");
57796
+ return joined.replace(
57797
+ /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g,
57798
+ (whole, charset, enc, text) => {
57799
+ try {
57800
+ const bytes = enc.toUpperCase() === "B" ? Buffer.from(text, "base64") : Buffer.from(
57801
+ text.replace(/_/g, " ").replace(
57802
+ /=([0-9A-Fa-f]{2})/g,
57803
+ (_m, h) => String.fromCharCode(parseInt(h, 16))
57804
+ ),
57805
+ "latin1"
57806
+ );
57807
+ return new TextDecoder(normalizeCharset(charset)).decode(bytes);
57808
+ } catch {
57809
+ return whole;
57810
+ }
57811
+ }
57812
+ );
57813
+ }
57814
+ function normalizeCharset(charset) {
57815
+ const bare = charset.split("*")[0].trim().toLowerCase();
57816
+ try {
57817
+ new TextDecoder(bare);
57818
+ return bare;
57819
+ } catch {
57820
+ return "utf-8";
57821
+ }
57822
+ }
57823
+ function bareId(raw) {
57824
+ return raw.trim().replace(/^<+/, "").replace(/>+$/, "").trim();
57825
+ }
57826
+ function splitIds(raw) {
57827
+ const bracketed = raw.match(/<[^>]+>/g);
57828
+ if (bracketed) return bracketed.map(bareId).filter(Boolean);
57829
+ return raw.split(/[\s,]+/).map(bareId).filter(Boolean);
57830
+ }
57831
+ function parseDateHeader(value) {
57832
+ const direct = new Date(value);
57833
+ if (!Number.isNaN(direct.getTime())) return direct;
57834
+ let replaced = value;
57835
+ for (const [abbr, en] of Object.entries(LOCALE_MONTHS)) {
57836
+ const re = new RegExp(`\\b${abbr}\\.?\\b`, "i");
57837
+ if (re.test(replaced)) {
57838
+ replaced = replaced.replace(re, en);
57839
+ break;
57840
+ }
57841
+ }
57842
+ if (replaced !== value) {
57843
+ const viaMonth = new Date(replaced);
57844
+ if (!Number.isNaN(viaMonth.getTime())) return viaMonth;
57845
+ }
57846
+ return void 0;
57847
+ }
57848
+ function splitFusedDate(headers) {
57849
+ const i = headers.findIndex((h) => h.name.toLowerCase() === "date");
57850
+ if (i === -1) return void 0;
57851
+ const m = /^([A-Za-z][A-Za-z0-9-]*):[ \t]?(.*)$/.exec(headers[i].value);
57852
+ if (!m) return void 0;
57853
+ const name = m[1];
57854
+ if (!FUSABLE_HEADER_NAMES.has(name.toLowerCase()) && !/^x-/i.test(name)) return void 0;
57855
+ headers.splice(i, 1, { name: headers[i].name, value: "" }, { name, value: m[2].trim() });
57856
+ return name;
57857
+ }
57858
+ function parseHeaderBlock(input) {
57859
+ const text = (input ?? "").replace(/\r\n|\r/g, "\n");
57860
+ const blank = text.search(/\n\n/);
57861
+ const raw = (blank === -1 ? text : text.slice(0, blank)).replace(/\n+$/, "");
57862
+ const headers = [];
57863
+ for (const line of raw.split("\n")) {
57864
+ if (/^[ \t]/.test(line) && headers.length) {
57865
+ headers[headers.length - 1].value += " " + line.trim();
57866
+ continue;
57867
+ }
57868
+ const m = /^([!-9;-~]+):[ \t]?(.*)$/.exec(line);
57869
+ if (!m) continue;
57870
+ headers.push({ name: m[1], value: m[2].trim() });
57871
+ }
57872
+ const warnings = [];
57873
+ const fused = splitFusedDate(headers);
57874
+ if (fused) {
57875
+ warnings.push(
57876
+ `The Date: header arrived with no value and the ${fused}: header joined onto it (Mail.app's all-headers property does this for a Date: it cannot parse). Split back into Date: and ${fused}:; the send date is unknown from this source.`
57877
+ );
57878
+ }
57879
+ const first = (name) => headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value;
57880
+ const all = (name) => headers.filter((h) => h.name.toLowerCase() === name.toLowerCase()).map((h) => h.value);
57881
+ const decoded = (name) => {
57882
+ const v = first(name);
57883
+ return v === void 0 ? void 0 : decodeEncodedWords(v);
57884
+ };
57885
+ const dateHeader = first("Date") || void 0;
57886
+ let date;
57887
+ if (dateHeader) {
57888
+ const parsed = parseDateHeader(dateHeader.replace(/\s*\([^)]*\)\s*$/, ""));
57889
+ if (parsed) date = parsed.toISOString();
57890
+ }
57891
+ const messageIdRaw = first("Message-ID") ?? first("Message-Id");
57892
+ const inReplyToRaw = first("In-Reply-To");
57893
+ const referencesRaw = first("References");
57894
+ return {
57895
+ raw,
57896
+ headers,
57897
+ date,
57898
+ dateHeader,
57899
+ messageId: messageIdRaw ? bareId(messageIdRaw) || void 0 : void 0,
57900
+ subject: decoded("Subject"),
57901
+ from: decoded("From"),
57902
+ to: decoded("To"),
57903
+ cc: decoded("Cc"),
57904
+ replyTo: decoded("Reply-To"),
57905
+ inReplyTo: inReplyToRaw ? bareId(inReplyToRaw) || void 0 : void 0,
57906
+ references: referencesRaw ? splitIds(referencesRaw) : [],
57907
+ received: all("Received"),
57908
+ ...warnings.length ? { warnings } : {}
57909
+ };
57910
+ }
57911
+ function plausibleDateSent(sent, received) {
57912
+ if (!sent || Number.isNaN(sent.getTime())) return void 0;
57913
+ if (!received || Number.isNaN(received.getTime())) return sent;
57914
+ return sent.getTime() - received.getTime() > MAX_SENT_AFTER_RECEIVED_MS ? void 0 : sent;
57915
+ }
57916
+ function headerBlockBytes(source, whole) {
57917
+ const cuts = [source.indexOf("\r\n\r\n"), source.indexOf("\n\n")].filter((i) => i !== -1);
57918
+ if (cuts.length) return source.subarray(0, Math.min(...cuts));
57919
+ return whole ? source : void 0;
57920
+ }
57921
+ function decodeHeaderBytes(bytes) {
57922
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
57923
+ const cp1252 = new TextDecoder("windows-1252");
57924
+ const lines = [];
57925
+ let start = 0;
57926
+ for (let i = 0; i <= bytes.length; i++) {
57927
+ if (i < bytes.length && bytes[i] !== 10) continue;
57928
+ const line = bytes.subarray(start, i);
57929
+ try {
57930
+ lines.push(utf8.decode(line));
57931
+ } catch {
57932
+ lines.push(cp1252.decode(line));
57933
+ }
57934
+ start = i + 1;
57935
+ }
57936
+ return lines.join("\n");
57937
+ }
57775
57938
  function isoOrUndefined(d) {
57776
57939
  if (d === void 0 || d === "") return void 0;
57777
57940
  const date = d instanceof Date ? d : new Date(d);
57778
57941
  return Number.isNaN(date.getTime()) ? void 0 : date.toISOString();
57779
57942
  }
57943
+ var LOCALE_MONTHS, FUSABLE_HEADER_NAMES, MAX_SENT_AFTER_RECEIVED_MS;
57780
57944
  var init_headers = __esm({
57781
57945
  "src/utils/headers.ts"() {
57782
57946
  "use strict";
57947
+ LOCALE_MONTHS = {
57948
+ // Spanish
57949
+ ene: "Jan",
57950
+ feb: "Feb",
57951
+ mar: "Mar",
57952
+ abr: "Apr",
57953
+ may: "May",
57954
+ jun: "Jun",
57955
+ jul: "Jul",
57956
+ ago: "Aug",
57957
+ sep: "Sep",
57958
+ set: "Sep",
57959
+ oct: "Oct",
57960
+ nov: "Nov",
57961
+ dic: "Dec",
57962
+ // French
57963
+ janv: "Jan",
57964
+ f\u00E9vr: "Feb",
57965
+ fevr: "Feb",
57966
+ avr: "Apr",
57967
+ mai: "May",
57968
+ juin: "Jun",
57969
+ juil: "Jul",
57970
+ ao\u00FBt: "Aug",
57971
+ aout: "Aug",
57972
+ d\u00E9c: "Dec",
57973
+ dec: "Dec",
57974
+ // German
57975
+ jan: "Jan",
57976
+ m\u00E4r: "Mar",
57977
+ maer: "Mar",
57978
+ mrz: "Mar",
57979
+ okt: "Oct",
57980
+ dez: "Dec",
57981
+ // Italian / Portuguese
57982
+ gen: "Jan",
57983
+ giu: "Jun",
57984
+ lug: "Jul",
57985
+ ott: "Oct",
57986
+ out: "Oct",
57987
+ fev: "Feb"
57988
+ };
57989
+ FUSABLE_HEADER_NAMES = /* @__PURE__ */ new Set([
57990
+ "subject",
57991
+ "from",
57992
+ "to",
57993
+ "cc",
57994
+ "bcc",
57995
+ "sender",
57996
+ "reply-to",
57997
+ "message-id",
57998
+ "in-reply-to",
57999
+ "references",
58000
+ "mime-version",
58001
+ "content-type",
58002
+ "content-transfer-encoding",
58003
+ "content-disposition",
58004
+ "return-path",
58005
+ "received",
58006
+ "importance",
58007
+ "priority",
58008
+ "thread-topic",
58009
+ "thread-index"
58010
+ ]);
58011
+ MAX_SENT_AFTER_RECEIVED_MS = 7 * 24 * 60 * 60 * 1e3;
57783
58012
  }
57784
58013
  });
57785
58014
 
@@ -57801,6 +58030,7 @@ var init_auditLog = __esm({
57801
58030
  // src/services/imapClient.ts
57802
58031
  var imapClient_exports = {};
57803
58032
  __export(imapClient_exports, {
58033
+ HEADER_WINDOW_BYTES: () => HEADER_WINDOW_BYTES,
57804
58034
  IMAP_ENV: () => IMAP_ENV,
57805
58035
  MAX_COMPOSE_SOURCE_BYTES: () => MAX_COMPOSE_SOURCE_BYTES,
57806
58036
  __resetPool: () => __resetPool,
@@ -58084,12 +58314,36 @@ function buildCriteria(a, listMode) {
58084
58314
  if (Object.keys(c).length === 0) c.all = true;
58085
58315
  return c;
58086
58316
  }
58317
+ function validDate(d) {
58318
+ if (!d) return void 0;
58319
+ const parsed = d instanceof Date ? d : new Date(d);
58320
+ return Number.isNaN(parsed.getTime()) ? void 0 : parsed;
58321
+ }
58322
+ function asBuffer(v) {
58323
+ return Buffer.isBuffer(v) ? v : Buffer.from(v);
58324
+ }
58325
+ function headerDate(m, headerText) {
58326
+ const env = m.envelope?.date;
58327
+ if (env instanceof Date) {
58328
+ if (!Number.isNaN(env.getTime())) return env;
58329
+ } else if (typeof env === "string" && env.trim()) {
58330
+ const parsed = parseDateHeader(env.replace(/\s*\([^)]*\)\s*$/, "").trim());
58331
+ if (parsed) return parsed;
58332
+ }
58333
+ const block = headerText ?? (m.headers ? decodeHeaderBytes(asBuffer(m.headers)) : "");
58334
+ const iso = block ? parseHeaderBlock(block).date : void 0;
58335
+ return iso ? new Date(iso) : void 0;
58336
+ }
58337
+ function sentDate(m, headerText) {
58338
+ return plausibleDateSent(headerDate(m, headerText), validDate(m.internalDate));
58339
+ }
58087
58340
  function formatRow(m, account, path) {
58088
58341
  const env = m.envelope ?? {};
58089
58342
  const subject = env.subject || "(no subject)";
58090
58343
  const a = env.from?.[0];
58091
58344
  const from = a ? a.name ? `${a.name} <${a.address ?? ""}>` : a.address ?? "(unknown)" : "(unknown)";
58092
- const date = env.date ? new Date(env.date).toLocaleDateString() : "";
58345
+ const shown = sentDate(m) ?? validDate(m.internalDate);
58346
+ const date = shown ? shown.toLocaleDateString() : "";
58093
58347
  const read = m.flags?.has("\\Seen") ? "read" : "unread";
58094
58348
  return ` - ID: ${encodeImapId(account, path, m.uid)} | ${date} | ${subject} (from: ${from}) [${read}]`;
58095
58349
  }
@@ -58110,8 +58364,10 @@ function structuredRow(m, account, path) {
58110
58364
  // emitted under the name that is true of them, matching `messageSummary`.
58111
58365
  // `dateReceived` falls back to the header date when the server withheld
58112
58366
  // INTERNALDATE, which is the old behaviour and never invents a value.
58113
- dateSent: isoOrEmpty(env.date),
58114
- dateReceived: isoOrEmpty(m.internalDate ?? env.date),
58367
+ // #234: both now see the RECOVERED header date (see headerDate), and
58368
+ // `dateSent` omits a value implausibly later than arrival (see sentDate).
58369
+ dateSent: isoOrEmpty(sentDate(m)),
58370
+ dateReceived: isoOrEmpty(validDate(m.internalDate) ?? headerDate(m)),
58115
58371
  isRead: m.flags?.has("\\Seen") ?? false,
58116
58372
  isFlagged: m.flags?.has("\\Flagged") ?? false,
58117
58373
  flagColorIndex: mailFlagColorIndex(m.flags),
@@ -58135,9 +58391,7 @@ function hasMailboxFlag(mailbox, wanted) {
58135
58391
  return [...mailbox.flags ?? []].some((flag) => flag.toLowerCase() === normalized);
58136
58392
  }
58137
58393
  function messageDateEpoch(message) {
58138
- if (!message.envelope?.date) return 0;
58139
- const epoch = new Date(message.envelope.date).getTime();
58140
- return Number.isNaN(epoch) ? 0 : epoch;
58394
+ return (sentDate(message) ?? validDate(message.internalDate))?.getTime() ?? 0;
58141
58395
  }
58142
58396
  function messageIdentity(entry) {
58143
58397
  const raw = entry.message.envelope?.messageId?.trim() ?? "";
@@ -58161,7 +58415,13 @@ async function fetchMailboxMatches(client, path, criteria, newestCount) {
58161
58415
  // INTERNALDATE rides along for the same reason, and is why `dateReceived`
58162
58416
  // can finally mean what it says: imapflow's `envelope.date` is built from
58163
58417
  // the header block, so it IS the `Date:` header, not arrival time.
58164
- { envelope: true, flags: true, bodyStructure: true, internalDate: true },
58418
+ //
58419
+ // The `Date:` header itself (BODY.PEEK[HEADER.FIELDS (DATE)]) rides in the
58420
+ // SAME FETCH command, so a date the server's ENVELOPE parser rejected can
58421
+ // still be recovered (#234). Measured on 50 real messages, 6 alternating
58422
+ // runs: median ~334ms without vs ~315ms with — inside the noise — for ~44
58423
+ // bytes per message. Too cheap to hide behind an opt-in.
58424
+ { envelope: true, flags: true, bodyStructure: true, internalDate: true, headers: ["date"] },
58165
58425
  { uid: true }
58166
58426
  )) {
58167
58427
  byUid.set(msg.uid, msg);
@@ -58641,16 +58901,32 @@ async function imapGetMessage(id, preferHtml, deps = {}) {
58641
58901
  );
58642
58902
  if (!msg)
58643
58903
  return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
58644
- const subject = msg.envelope?.subject || "(no subject)";
58645
- const src = msg.source ? msg.source.toString() : "";
58646
- const body = (preferHtml ? extractHtmlBody(src) : extractTextBody(src)) ?? extractTextBody(src) ?? extractHtmlBody(src) ?? "(no readable body)";
58904
+ const sourceBytes = msg.source ? asBuffer(msg.source) : Buffer.alloc(0);
58905
+ const src = sourceBytes.toString("latin1");
58906
+ const headerBytes = sourceBytes.length ? headerBlockBytes(sourceBytes, true) : void 0;
58907
+ const headerText = headerBytes ? decodeHeaderBytes(headerBytes) : void 0;
58908
+ const subject = (headerText ? parseHeaderBlock(headerText).subject : void 0) || msg.envelope?.subject || "(no subject)";
58909
+ let body;
58910
+ let isHtml = false;
58911
+ if (preferHtml) {
58912
+ body = extractHtmlBody(src);
58913
+ isHtml = body !== null;
58914
+ body ??= extractTextBody(src);
58915
+ } else {
58916
+ body = extractTextBody(src);
58917
+ if (body === null) {
58918
+ body = extractHtmlBody(src);
58919
+ isHtml = body !== null;
58920
+ }
58921
+ }
58647
58922
  return {
58648
58923
  success: true,
58649
58924
  info: `Subject: ${subject}
58650
58925
 
58651
- ${body}`,
58926
+ ${body ?? "(no readable body)"}`,
58652
58927
  meta: {
58653
- dateSent: isoOrUndefined(msg.envelope?.date),
58928
+ isHtml,
58929
+ dateSent: isoOrUndefined(sentDate(msg, headerText)),
58654
58930
  dateReceived: isoOrUndefined(msg.internalDate),
58655
58931
  // The envelope carries the Message-ID; `info` deliberately does not (it
58656
58932
  // is subject + body), so the caller could never recover it from there.
@@ -58665,12 +58941,18 @@ async function imapGetMessageHeaders(id, deps = {}) {
58665
58941
  return withMailbox(ref.path, depsForMessageRef(ref, deps), async (client) => {
58666
58942
  const msg = await client.fetchOne(
58667
58943
  String(ref.uid),
58668
- { envelope: true, internalDate: true, headers: true },
58944
+ { envelope: true, internalDate: true, source: { start: 0, maxLength: HEADER_WINDOW_BYTES } },
58669
58945
  { uid: true }
58670
58946
  );
58671
58947
  if (!msg)
58672
58948
  return { success: false, error: `IMAP message UID ${ref.uid} not found in "${ref.path}".` };
58673
- const raw = msg.headers ? msg.headers.toString() : "";
58949
+ const window = msg.source ? asBuffer(msg.source) : Buffer.alloc(0);
58950
+ const block = window.length ? headerBlockBytes(window, window.length < HEADER_WINDOW_BYTES) : void 0;
58951
+ let raw = block ? decodeHeaderBytes(block) : "";
58952
+ if (!raw.trim()) {
58953
+ const h = await client.fetchOne(String(ref.uid), { headers: true }, { uid: true });
58954
+ raw = h && h.headers ? decodeHeaderBytes(asBuffer(h.headers)) : "";
58955
+ }
58674
58956
  if (!raw.trim()) return { success: false, error: "IMAP returned no header block." };
58675
58957
  return {
58676
58958
  success: true,
@@ -59040,7 +59322,7 @@ function senderName(from) {
59040
59322
  return a.name ? `${a.name} <${a.address ?? ""}>` : a.address ?? "(unknown)";
59041
59323
  }
59042
59324
  function dateMs(m) {
59043
- return m.envelope?.date ? new Date(m.envelope.date).getTime() : 0;
59325
+ return messageDateEpoch(m);
59044
59326
  }
59045
59327
  async function imapThread(id, deps = {}, limit = 50) {
59046
59328
  const ref = decodeImapId(id);
@@ -59080,7 +59362,16 @@ async function imapThread(id, deps = {}, limit = 50) {
59080
59362
  // Same reason as the list/search fetch: get-thread emits structured
59081
59363
  // rows too, so it needs BODYSTRUCTURE or its hasAttachments would
59082
59364
  // silently disagree with the same message seen via search.
59083
- { envelope: true, flags: true, bodyStructure: true },
59365
+ // INTERNALDATE and the Date: header ride along for the same reason as
59366
+ // the list/search fetch: the per-message date is recovered and
59367
+ // sanity-checked exactly as a row's dateSent is (#234).
59368
+ {
59369
+ envelope: true,
59370
+ flags: true,
59371
+ bodyStructure: true,
59372
+ internalDate: true,
59373
+ headers: ["date"]
59374
+ },
59084
59375
  { uid: true }
59085
59376
  )) {
59086
59377
  msgs.push(msg);
@@ -59099,7 +59390,7 @@ async function imapThread(id, deps = {}, limit = 50) {
59099
59390
  // IMAP server's own ENVELOPE parser couldn't normalize) threw
59100
59391
  // `RangeError: Invalid time value` out of get-thread (#226 follow-up).
59101
59392
  // Same omitted-not-invented contract as `structuredRow` (2.19.2).
59102
- date: isoOrEmpty(m.envelope?.date),
59393
+ date: isoOrEmpty(sentDate(m)),
59103
59394
  isRead: m.flags?.has("\\Seen") ?? false
59104
59395
  }))
59105
59396
  };
@@ -59113,7 +59404,7 @@ async function imapThread(id, deps = {}, limit = 50) {
59113
59404
  true
59114
59405
  );
59115
59406
  }
59116
- var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, poolConnect, pools, connecting, MAX_COMPOSE_SOURCE_BYTES, MAIL_FLAG_BITS, imapMarkRead, imapMarkUnread, FALLBACK_TRASH_PATH, imapBatchMarkRead, imapBatchMarkUnread, imapBatchFlag, imapBatchUnflag, imapBatchDelete;
59407
+ var import_imapflow, IMAP_ENV, defaultConnect, SPECIAL_USE_ALIASES, poolConnect, pools, connecting, MAX_COMPOSE_SOURCE_BYTES, HEADER_WINDOW_BYTES, MAIL_FLAG_BITS, imapMarkRead, imapMarkUnread, FALLBACK_TRASH_PATH, imapBatchMarkRead, imapBatchMarkUnread, imapBatchFlag, imapBatchUnflag, imapBatchDelete;
59117
59408
  var init_imapClient = __esm({
59118
59409
  "src/services/imapClient.ts"() {
59119
59410
  "use strict";
@@ -59169,6 +59460,7 @@ var init_imapClient = __esm({
59169
59460
  pools = /* @__PURE__ */ new Map();
59170
59461
  connecting = /* @__PURE__ */ new Map();
59171
59462
  MAX_COMPOSE_SOURCE_BYTES = 25 * 1024 * 1024;
59463
+ HEADER_WINDOW_BYTES = 64 * 1024;
59172
59464
  MAIL_FLAG_BITS = ["$MailFlagBit0", "$MailFlagBit1", "$MailFlagBit2"];
59173
59465
  imapMarkRead = (id, deps = {}) => flagOp(id, "\\Seen", true, deps);
59174
59466
  imapMarkUnread = (id, deps = {}) => flagOp(id, "\\Seen", false, deps);