Skip to content

Read Apple Mail .emlx Files: Decode Quoted-Printable Bodies, Skip Base64 Attachments, and Handle Mixed-Case Headers - #201

Open
Ocean572 wants to merge 1 commit into
ARahim3:mainfrom
Ocean572:fix/emlx-index-146
Open

Ocean572 wants to merge 1 commit into
ARahim3:mainfrom
Ocean572:fix/emlx-index-146

Conversation

@Ocean572

@Ocean572 Ocean572 commented Nov 6, 2026 •

Copy link
Copy Markdown

Closes #146.

Problem

Apple Mail stores messages in .emlx files, which can also appear inside .mailbundle folders.

Previously, these files were indexed incorrectly because the document extractor did not properly handle several email-specific encoding formats:

  • Quoted-printable bodies were indexed without decoding, leaving sequences such as =3D, =A0, and =0A in the searchable text.
  • Base64 attachments were included in the extracted text, introducing large amounts of unreadable binary data.
  • Mixed-case headers were not reliably recognized. Header names written as Subject: or Content-Type: could be missed because the existing lookup did not account for capitalization.

The reporter's --lines 3 reproduction showed quoted-printable escapes and base64 content appearing directly in the indexed output.

Changes

DocExtractor now recognizes Apple Mail's .emlx structure and handles its common MIME encoding formats.

1. Quoted-Printable Decoding

Quoted-printable decoding has been rewritten to use a single linear pass over substring views rather than scanning the input character by character.

The implementation handles:

  • Soft line breaks: Removes encoded line continuations so words split across lines are reconstructed correctly.
  • Encoded characters: Decodes sequences such as =3D and =A0.
  • UTF-8 byte sequences: Handles encoded UTF-8 sequences such as =C2 and their associated continuation bytes.
  • RFC 2047 encoded words: Supports both quoted-printable-style and base64-encoded header words.

An important compatibility detail is that RFC 2047 encoded words found in real-world messages are sometimes unpadded.

Before base64 decoding, encoded words are padded to a length divisible by four (% 4 == 0). This prevents the final encoded word from being truncated during decoding.

The decoder also handles the earliest applicable _ or = marker according to the RFC 2047 encoded-word rules.

Bounds checking using prefix(2).count protects short trailing input from invalid substring access.

2. Exclude Base64 Attachments from Searchable Text

Base64-encoded MIME attachments are now recognized and excluded from extracted searchable text.

This prevents binary attachment data from polluting the document index while preserving the actual message content.

The result is that:

  • The email body remains searchable.
  • Normal message text is retained.
  • Base64 attachment payloads are omitted.

3. Case-Insensitive Header Lookup

Header field lookup now uses case-insensitive matching through:

NSString.range(of:options:)

This ensures that headers are recognized regardless of capitalization.

For example, all of the following now match:

  • Subject:
  • SUBJECT:
  • subject:
  • Content-Type:
  • CONTENT-TYPE:

The implementation uses NSString.range(of:options:) because firstRange(of:options:) is unavailable on the current deployment target.

4. Read Each File Only Once

The extractor previously read the same file multiple times during its processing passes.

This has been simplified to a single readText operation, with subsequent processing performed against the already-loaded contents.

This avoids unnecessary filesystem reads and reduces redundant work during extraction.

Performance Measurements

Measured on the development machine:

Hardware and environment: Apple M1, 8 GB RAM, macOS 26.5.1.

Quoted-Printable Decoding

A 128,000-escape input body was used to compare the previous and new decoding implementations.

Implementation Execution time
Previous per-character scanning 73.9 s
New linear-pass decoder 0.15 s

~493× faster

The previous implementation repeatedly scanned the input at the character level, resulting in quadratic behavior.

The new implementation processes substring views in a single linear pass, dramatically reducing decoding time.

End-to-End Verification

Tested against a real multipart .emlx message.

DocExtractor.text successfully returns the decoded message body with:

  • No remaining =3D or =0A quoted-printable escapes.
  • No base64 attachment payloads.
  • The original searchable message text preserved.

Testing

The Swift test suite now contains 137 passing tests.

This includes 12 new tests covering:

  • Quoted-printable encoding and decoding.
  • Soft line breaks and encoded characters.
  • UTF-8 encoded sequences.
  • RFC 2047 encoded words.
  • Unpadded base64 encoded words.
  • Base64 attachment exclusion.
  • Mixed-case email headers.
  • .mailbundle directory extraction.

Scope and Limitations

The following cases remain outside the supported decoding behavior:

  • Non-standard character sets: .emlx bodies using non-standard encodings are currently decoded using UTF-8 and Latin-1 handling only.
  • Malformed MIME boundaries: If a message contains an invalid or malformed MIME boundary marker, extraction falls back to treating the content as plain text. The file remains indexable, but encoding artifacts may remain visible in the extracted text.
  • Deployment target compatibility: The implementation uses NSString.range(of:options:) for case-insensitive header matching because firstRange(of:options:) is unavailable on the current deployment target.

…text bodies, quoted-printable and base64 decoded, HTML stripped

Issue ARahim3#146 asked DigUp to search mail content the way it searches PDFs and
documents. This is the file-reading slice of that request: `.emlx` joins
`docExtensions` and `MailMessage` turns one message's bytes into search text —
the From/To/Cc/Subject/Date header lines first (continuation lines joined,
RFC 2047 =?utf-8?Q?…?= / =?utf-8?B?…?= encoded words decoded, unpadded base64
words padded), then each `text/*` body: quoted-printable (`=3D` hex escapes
collected as bytes so `=C3=A9` lands as one letter, `=`-soft-breaks joined —
a CRLF pair is a single Character in Swift), base64, HTML stripped through the
existing `plainText(fromHTML:)`; multipart bodies nest to eight levels by
their boundary. Attachments are left out: their files are indexed beside the
message by their own readers, and a message whose only body is an attachment
keeps its header lines, which is the searchable part of it. A file the reader
can't make sense of answers nil and `DocExtractor` keeps the raw text, so this
never reads worse than today's treatment of the file as plain text.

Found in the pre-push review and fixed here (both worth knowing for newline and
lowercase-index code generally): the MIME parameter search runs case-insensitively
on the value itself, never by slicing with indices from `value.lowercased()` —
lowering can change a string's UTF-8 length (`İ` is 2 bytes, its lower case 3)
and slicing the original with them is a fatal trap callers cannot catch, and
mail headers are untrusted input; encoded-word decoding takes the earlier of the
next `_` and `=` markers (a hex escape written before a later `_` used to stop
at the `_` and leave the raw `=C3=A9` in the subject — routine in accented mail);
and the quoted-printable walk stays on views of the same string — rebuilding
the rest per escape (and `Substring.count`, which scans the whole rest) made it
quadratic: 128k dense escapes measured 73.9 s before, 0.15 s after, on this box.

Deliberately NOT a mail client (the issue's full idea — Envelope Index,
message:// opening, a Settings source with Full Disk Access — is its own
feature, for the maintainer to shape): attachments are not unpacked, DKIM and
MIME quirks are not parsed, boundaries are matched literally (an RFC 2045
comment inside a boundary parameter is not honored), and encoded-word charsets
are read as UTF-8 (what mail uses today; a legacy single-byte word keeps its
bytes as written). Mail.app's own database is not read, and no new permissions
are asked for: the files are indexed like any other document, wherever the
person put their search folders.

Verified on this box: full swift test 137 green = 125 base suite + 12 new
MailMessageTests (header lines, QP with multi-byte accents and soft breaks,
HTML stripping, base64 bodies, attachments out with headers kept, attachment-
only messages, nested multipart, encoded words incl. mixed `_`/hex words,
quoted boundary tokens incl. a lowering-shrinks charset word, raw fallback,
extension registration); an end-to-end probe through `DocExtractor.text(of:)`
on a real multipart `.emlx` file on disk (QP plain part + base64 HTML part)
produced the header lines and both bodies' words; the quadratic-measure probe
above. Not verified: indexing a live ~/Library/Mail tree (needs Full Disk
Access acceptance in the app; the classifier treats .emlx like any doc file),
and no evals run — ranking code is untouched and the extractor dispatch is
per-extension, so existing corpora index identically (disclosed).
@Ocean572 Ocean572 changed the title Read Apple Mail's .emlx: quoted-printable bodies, base64 attachments, mixed-case headers Read Apple Mail .emlx Files: Decode Quoted-Printable Bodies, Skip Base64 Attachments, and Handle Mixed-Case Headers Nov 6, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: index Apple Mail messages (.emlx) on macOS

1 participant