Email content
MailFathom serves the content of the emails one call names, from its local copy. EmailContentReader is the second read
use case: it takes the stable local identifiers a listing returned and answers for each of them with normalized headers,
the body as plain text, optionally a sanitized HTML representation, attachment counts, optionally per-attachment
metadata without any bytes, the source account and folder alias, and the remote flag snapshot.
It reaches no mail server. That is structural rather than a rule someone keeps: the use case is constructed from a
summary reader, a content store, a renderer, a repair-request store, and the account catalog, and none of them can open
an IMAP session. Reading an email therefore cannot download it and cannot set the remote \Seen flag, whether or not
the local copy turns out to be usable.
The protocol adapter is not part of this. EmailContentReader is an application use case; the get_email_content MCP
tool that maps onto it is documented in MCP tools.
The request contract
GetEmailContentRequest carries three values and is built through Create, which enforces the two refusals below.
| Field | Meaning | Absent means |
|---|---|---|
StoredEmailIds |
The emails to read, named by the identities a listing returned, in the order to read them | — |
IncludeSanitizedHtml |
Whether to also produce the sanitized HTML representation of each body | plain text only |
IncludeAttachmentDetails |
Whether to describe each attachment rather than only count them | counts only |
Both flags govern the whole call rather than one email each. A caller asking for markup or for attachment descriptions wants them for what it is about to read, and a flag per identifier would make the argument list grow with the batch while answering a question no caller asks per email.
The HTML representation is opt-in because it costs a sanitization pass over untrusted markup and because plain text is what most callers want: a model reading mail is better served by the words than by the layout around them. The attachment descriptions are opt-in for a different reason, recorded under Attachments.
Reading several emails, and what bounds it
A listing returns up to 100 summaries and a search up to 50 ranked matches, so a caller can always name more emails than one read serves. Two bounds close that gap, and they answer different questions.
| Bound | Value | What it limits |
|---|---|---|
GetEmailContentRequest.MaximumEmails |
10 | How many emails one call may name |
EmailContent:MaxCharactersPerRead |
2 000 – 2 000 000, default 200 000 | How many body characters one call returns in total |
The count is a const rather than configuration, because it bounds a protocol call's shape rather than a deployment's
appetite; the volume is configured, because how much mail a response can usefully carry depends on what a deployment's
mail looks like. Neither replaces the other: without the count one call could name a mailbox, and without the budget ten
emails could each return MaxBodyCharacters in full.
Two refusals are decided before anything is read, and both refuse rather than repair:
- More than ten emails, or none at all, is
51005 EmailContentReadCountOutOfRange. A truncated answer would leave a caller comparing what came back against its own list to find out which emails it did not receive. - The same email named twice is
51006 EmailContentReadDuplicateEmail. Serving it twice spends the read's character budget on content the caller already holds and displaces an email it has not read; collapsing it returns fewer entries than were named, which a caller reading results positionally cannot detect.
Both are the request's own invariant, enforced in Create, so an entrypoint added later cannot reach the use case with a
list nobody counted.
What a result carries
GetEmailContentResult is the most sensitive projection MailFathom publishes. It is message content in full and inherits
every classification, retention, access, and erasure constraint of the mail it was read from. Nothing in it is logged.
It carries one EmailContentReadOutcome per named email, in the order they were named. That order is the contract twice
over: it is how a caller pairs an outcome with what it asked for, and it is the order the character budget was spent in.
Each outcome names the email it answers for and carries exactly one of two things.
| Field | Meaning |
|---|---|
StoredEmailId |
The email this outcome answers for, present whether or not there was content |
Content |
The email as a reader receives it, or absent when it could not be served |
Failure |
The stable code and message saying why there is no content, or absent when there is |
An email this deployment cannot serve therefore costs the caller that email and nothing else it asked about. That is why
the two per-email findings became results rather than exceptions: the reader that discovers them keeps going, and the
repository's failure rules reserve an exception for a fact that must travel past the code able to decide what it means.
The codes are the ones a single-email read already published, so a caller matching on 53002 or 55001 reads the same
fact whether it named one email or ten.
ReadEmailContent is the content half.
| Field | Meaning |
|---|---|
StoredEmailId, AccountId, FolderAlias |
Where the email is, in MailFathom's own names |
SizeOctets |
The size the mail server reported for the whole message |
Headers |
Subject, sent and received timestamps, every participant under its header role, and the thread identifiers |
Body |
The representations, or the reason there are none |
AttachmentSummary |
The counts for what the message carries besides its body, absent when nobody has counted them |
Attachments |
One entry per attachment when the request asked for them, re-derived from the stored raw MIME, with no bytes |
RemoteFlags |
The flags a server last showed, and when they were read |
Headers come from the message, not from the row
The headers are read during the same parse that produces the body rather than from the columns a listing is served out
of. The row keeps only the comparison forms a filter needs, so display names, a Bcc a message carries for its own
recipient, and the Sender header exist nowhere else — a reader shown the listing's copy would be shown a narrower
message than the one that arrived.
Both header lists a sender controls the length of are bounded where the parse produces them: at most 256 participants per header role, and at most 256 thread references, of which an over-long path keeps its root and its most recent ancestors. The reference bound is applied while the header is read rather than to the list it produced, so a sender who writes a hundred thousand ancestors costs the parse the memory of the ones it keeps. Each thread identifier is bounded in itself as well, at the 998 octets RFC 5322 allows a header line: a longer one is refused rather than cut, because a prefix of a message identifier is an identifier another message may legitimately carry. The persisted columns bound the same values more narrowly, deliberately — one bound is about what a parse publishes to a reader and the other about what a column stores.
Attachments are counted always, and described on request
How many attachments an email carries is answered by every read. What each one is called is answered only when
IncludeAttachmentDetails asks: a file name is text the sender chose, it is frequently the most identifying string a
message carries, and a read that only wanted the body never asked for it. list_emails already publishes counts and
never names, so withholding the names here makes the two read models agree about the same data rather than disagree for
no reason a caller stated.
The default is never silently lossy. AttachmentSummary states how many attachments exist, their total decoded size,
the inline-resource count, and the encryption, signature, and TNEF flags whichever way the flag is set, so a caller can
tell that asking again would describe something rather than concluding the message carries nothing. Attachments is
absent rather than empty in that case, which keeps "you did not ask" and "there are none" apart.
Attachment bytes are unchanged by any of this: they were never returned under any setting and still are not.
The descriptions are re-derived, never stored
The per-attachment list — the normalized file name, the media type, and the decoded size — is produced by the parse this read already performs, following the classification rule MIME metadata extraction defines. It is not persisted, because file names are mail content and the stored schema deliberately keeps only the indexable summary. Re-deriving costs nothing extra and guarantees the list cannot drift from the message it describes.
Inline resources and cryptographic parts never appear in the list. They are reported as counts on AttachmentSummary
instead, so a signed message and a message with a logo in its signature block do not look like mail with files attached.
Those counts come from the same parse as the list whenever the stored MIME could be read, so the two can never disagree. They would if the row answered for them: a message stored before extraction ran records no attachments until the backfill reaches it, while the message it describes has them.
Where there is nothing to parse — content the size limit kept out of storage — the summary is absent rather than zero. Nothing has ever read that message's parts: synchronization recorded what the server's envelope reported, and an envelope describes no attachments, so the row's zero counts are unset defaults rather than a finding. Publishing them would tell a caller that every oversized message carries no attachments, which is a claim nothing here is in a position to make.
Each header role contributes at most 256 participants. Nothing between a sender and this system bounds how many addresses a header may carry, so without it one message could decide how large every result derived from it becomes. The persisted columns carry a bound of their own, deliberately: this one bounds what a parse publishes, that one bounds what a column stores.
File names arrive normalized: path structure, control characters, and bidirectional overrides are removed when the name is read, and a name is never returned as a path or resolved against one.
The result type has nowhere to put attachment content. That is a property of the contract rather than of a caller's discipline, and a unit test asserts it.
The body, and the three ways there is none
EmailContentBody states which case a reader is in.
Availability |
Meaning |
|---|---|
Readable |
The body was read; an empty one means the message displayed nothing |
EncryptedNotReadableLocally |
The body arrived inside a cryptographic envelope and nothing here can read it |
NotStoredExceededSizeLimit |
The raw MIME exceeded MailSynchronization:MaxRawMimeBytes, so it was never stored |
An encrypted body is a state rather than an empty string, because merging the two would make mail this deployment holds and cannot decrypt indistinguishable from mail that genuinely said nothing. Decrypting it is out of scope and is tracked by #75.
The state means what it says: nothing could read the body. A multipart/alternative may offer a readable text/plain
member beside an encrypted one, and the message then has a body a reader can be shown — so it is reported as readable,
even though the attachment summary still records that the message carries encrypted content somewhere. The unreadable
state is reserved for a body that left nothing behind.
"Left nothing behind" is measured on what the message wrote rather than on what a call returned. A read's character
budget can empty the representation of such a message for a reason belonging to the call rather than to the mail — the
emails named before it spent the budget — and reporting that as the encrypted state would tell a caller the message can
never be read locally when naming it alone returns the readable alternative in full. It stays readable, with
readCharacterBudget saying what cut it.
NotStoredExceededSizeLimit is not a defect and schedules no repair: synchronization recorded the occurrence and
deliberately stored no content for it, and asking for repair would ask a later run to store what the same limit refuses
again. Everything answerable is still answered — the headers from the stored row, the attachment counts from the summary
written when the occurrence was recorded — and only the per-attachment list is absent, because nothing local can derive
it.
Plain text is the default representation and is always present, empty in each of the states where nothing could be read.
A genuine text/plain part wins over every HTML alternative; HTML is read only when the message offered no plain-text
one. Unlike the text the lexical index covers, nothing is trimmed: quoted history and a signature block are part of the
message a person asked to read.
Truncation is always explicit, and names the bound that cut
Each representation carries its own EmailBodyRepresentation: the text as returned, the number of characters its source
held, and which bound removed something. A caller therefore never has to guess whether it received a whole message, and a
message can exceed a bound in one representation without affecting the other.
EmailBodyTruncation names the bound rather than merely reporting that there was one, because the two lead a caller to
different actions.
Truncation |
Meaning | What a caller does about it |
|---|---|---|
None |
The text is the whole of what the message displayed in this representation | Nothing |
BodyCharacterLimit |
The per-representation bound cut it | Nothing; this message is longer than any single call returns |
ReadCharacterBudget |
The call's total budget cut it, because the emails named before it had already spent it | Name this email in a call of its own, or fewer emails at once |
EmailContent:MaxBodyCharacters sets the per-representation bound, defaults to 100,000, and is validated at startup
within 1,000–1,000,000. EmailContent:MaxCharactersPerRead sets the whole call's budget, defaults to 200,000, and is
validated within 2,000–2,000,000 and at no less than twice MaxBodyCharacters. Together they decide how much of a body a
caller is handed; what bounds this process is MailSynchronization:MaxRawMimeBytes, which no stored message is above.
{
"EmailContent": {
"MaxBodyCharacters": 100000,
"MaxCharactersPerRead": 200000
}
}
The budget is at least twice the per-body bound because one email asking for both representations may return that bound twice. A smaller budget would cut a one-email call by a limit that exists for calls naming several, and the truncation it reported would send a caller to split a call it cannot split further. Startup is where that is caught, because the alternative is a deployment discovering it one read at a time.
The budget is spent in the order the emails were named, and it counts both representations, because both are message
content the caller received. Within it, each representation of each email is still bounded by MaxBodyCharacters, and
the plain text is bounded before the markup so the representation every caller receives is never starved by the one it
opted into. An email reached after the budget has run out returns an empty text that says ReadCharacterBudget cut it,
rather than failing the call: the emails already read are what the caller keeps.
The section is one of its own rather than a value inside the synchronization settings, because it bounds a read rather than a fetch: it applies whether or not synchronization is enabled, and changing it changes no stored data. The section is bound strictly, so a misspelled key fails startup instead of being replaced by the default.
The cut falls on a text-element boundary, so a body ending in an emoji or a combining sequence is never handed over as a lone surrogate that a JSON writer would replace and PostgreSQL would reject.
Plain text is read in full and then cut, so the reported original length is the length that actually existed. Its edges are left exactly as the sender wrote them — a leading indent can be the first line of a code block and a trailing blank line can be the shape of a signature. Text derived from HTML is trimmed, because its edge whitespace belongs to the derivation rather than to the message: a body opening with a block element emits a line break before its first word.
Markup is cut before it is parsed: sanitizing is the expensive step, and there is nothing to learn from parsing what will not be returned. The sanitizer's parse then closes what the cut left open, so a truncated HTML representation is still balanced markup, and its truncation is measured against the source it was cut from.
Closing those elements adds characters, so a source that fits the bound can serialize past it — deeply nested markup can spend its whole allowance on opening tags and then need as much again to close them. Rather than cut the result, which would hand back exactly the unbalanced fragment the source-first cut avoids, the source is shrunk and sanitized again until the result fits. The retry terminates because a shorter prefix opens no more elements than a longer one, and ordinary mail never reaches a second pass.
HTML sanitization
Message HTML is treated as hostile input. The policy is an allow-list at every level the sanitizer offers — elements, attributes, CSS properties, CSS at-rules, and URI schemes — because a deny-list cannot be proven complete.
- No URI scheme is allowed at all. Every
href,src, and other reference is removed rather than filtered. Nothing here can prove which attributes a given client resolves without being asked, so no reference survives to find out: no remote image is fetched, no linked resource is loaded, and no tracking URL is left for a renderer to open. cid:references fall with them, deliberately. They point at parts of the same message, this read never returns part bytes, and a client resolving content identifiers against something other than the message would follow one somewhere unintended. The inline-resource count is what a caller reports instead — that the message contained embedded images, rather than a gap where one was.- Style is removed entirely, both the
styleattribute and<style>elements with their contents. That is where a body hides a reference behind aurl()and where an at-rule imports one. - Scripts, event handlers, embedded objects, forms, and inputs are removed, each with its contents.
altandtitlesurvive, because what a stripped image was is the only thing a reader is left with. So docolspan,rowspan,dir, andlang.templateis not on the allow-list and must never be added. Its contents were the subject of CVE-2026-25543 (GHSA-j92c-7v7g-gj3f), fixed in the pinned version and only ever exploitable where the element had been allowed explicitly.
A disallowed element is removed with its content rather than unwrapped. Unwrapping would keep the text a <script>
element carries, which is inert but indistinguishable from the message's own words. The element allow-list is therefore
generous about the presentational elements mail actually uses — font, center, big — whose attributes are stripped
anyway, so removing an element is rare.
One consequence follows from that choice and is worth stating: an unclosed disallowed container takes with it whatever
the parser nested inside it. A message ending in an unclosed <iframe> loses the text after it, which is the same text
a browser would not display either.
When the local copy is unusable
Missing or damaged content is an expected outcome, not a crash. The read verifies what is stored against the length and SHA-256 digest recorded beside it when it was written, and four things can be wrong.
| Defect | What it means |
|---|---|
Missing |
The row says content is stored and none is |
ByteLengthMismatch |
The payload is not as long as was recorded, which is what a partial write leaves |
HashMismatch |
The payload is the right length and its bytes changed |
Unreadable |
The payload is intact and still yields no message a parser can render |
In every case the read records a durable repair request and reports that email as 55001 EmailContentUnavailable. The
request is recorded first, so the finding survives whether or not the caller acts on what comes back; performing the
repair belongs to the synchronizer and is out of scope here. The request is idempotent per email — PostgreSQL resolves
the collision itself — so a caller retrying a damaged message leaves one row with an accurate count rather than a row per
attempt. The other emails of the same call are read and returned as usual.
The three fetch-again defects stay distinct from Unreadable because they say different things to whoever repairs them:
a second fetch fixes the first three and may well reproduce the fourth. Which one was found is named in the failure's
message.
An email the local copy holds no row for, or one belonging to an account this deployment no longer serves, is reported as
53002 StoredEmailNotFound. One failure covers both, for the reason 53001 MailAccountNotAccessible covers both of its
cases: a caller that could tell them apart could learn which identifiers exist by asking.
The two codes are distinct on purpose. StoredEmailNotFound names an email that was never stored here;
EmailContentUnavailable names one that is stored and whose body cannot currently be served, and only the second is
worth retrying.
Both are per-email outcomes rather than raised failures, so neither ends a call. What does end a call is a refusal of the request itself — a count outside the bound, a repeated identifier, or text that names no email at all — because none of those leaves an email to report an outcome against.
Where the pieces live
MailFathom.Application.Emails.GetEmailContent— the use case, its request, its per-email outcome and failure, and the two refusals a request itself can earn.MailFathom.Application.EmailContent.Storage— the content store port and what a read of it returns, remote and stored.MailFathom.Application.EmailContent.Rendering— the renderer port, the body representations with their bounds, and the headers.MailFathom.Application.EmailContent.Repair— the repair-request port, the request it carries, and the defect that raises one.MailFathom.Infrastructure.Mail.Mime—MimeKitEmailContentRendererandEmailHtmlSanitizer, which own the MIME parser and the HTML sanitizer respectively. Neither type escapes that namespace.MailFathom.Infrastructure.Persistence.Emails—StoredEmailSummaryReader, the content store's integrity-bearing read, andEmailContentRepairRequestStore.
MimeMessageHeaderReader is shared with the extraction that fills the lexical index, so a message is indexed under
exactly the headers it is displayed under.