Rafters Mail

Blob Storage

How email content is stored and retrieved.


Why blob storage

Email bodies and attachments are large (kilobytes to megabytes). Storing them inline in the database would bloat query performance and row sizes. Instead, the database stores metadata and blob keys. The actual content lives in blob storage.


The blob storage adapter

interface BlobStorage {
  put(key: string, content: string | ArrayBuffer, options?: BlobPutOptions): Promise<void>;
  get(key: string, options?: BlobGetOptions): Promise<BlobObject | null>;
  delete(key: string): Promise<void>;
  generateKey(contentHash: string, extension: string): string;
}

interface BlobObject {
  text(): Promise<string>;
  arrayBuffer(): Promise<ArrayBuffer>;
  httpMetadata?: Record<string, string>;
  customMetadata?: Record<string, string>;
}

interface BlobPutOptions {
  httpMetadata?: Record<string, string>;
  customMetadata?: Record<string, string>;
}

interface BlobGetOptions {
  range?: { offset: number; length: number };
}

get returns a lazy BlobObject (not a string) so consumers choose whether to decode as text or binary via .text() / .arrayBuffer(). Partial reads are supported through BlobGetOptions.range, which IMAP FETCH BODY[]<offset.length> maps directly onto.

generateKey(contentHash, extension) centralizes the key schema so applications do not hand-roll paths. The Cloudflare implementation builds keys of the form emails/{year}/{month}/{contentHash}.{extension}.

Any storage backend that supports key-value blob operations works: object storage (S3-compatible), file system, KV stores. Ship-ready adapters in the repo: createR2Storage from @rafters/mail-cloudflare/storage.


What gets stored

Each inbound message produces up to three blobs:

BlobKey suffixContentPurpose
Raw.emlComplete RFC 5322 messageIMAP FETCH BODY[], archival
HTML.htmlExtracted HTML bodyIMAP FETCH BODY[TEXT], web display
Text.txtExtracted plain text bodySnippets, search, plain text clients

The raw blob is required. HTML and text blobs are optional (a plain-text-only email has no HTML blob).


Key format

Keys follow a date-partitioned, content-addressed pattern:

emails/{year}/{month}/{content-hash}.{extension}

Example: emails/2026/04/a1b2c3d4e5f6.eml

  • Date partitioning: groups blobs by month for lifecycle management
  • Content addressing: the hash is derived from the email content, so duplicate emails produce the same key
  • Month is zero-padded: 04 not 4

Immutability

Blobs are write-once. Once stored, they are never modified. This is essential for:

  • IMAP correctness: IMAP UIDs point to immutable messages. If content could change, the UID mapping would be unreliable.
  • Threading integrity: In-Reply-To/References point to specific messages by Message-ID. The content those references describe must be stable.
  • Archival: the raw .eml blob is the legal record of the email as received.

Deletion happens only on EXPUNGE (permanent removal of a \Deleted message). The blob is deleted alongside the database record.


Size tracking

The message record stores sizeBytes, the byte size of the raw blob. This is used by:

  • IMAP FETCH RFC822.SIZE
  • IMAP SEARCH LARGER/SMALLER
  • Dashboard storage usage reporting

Attachments

Attachments are parsed from the MIME structure of the raw email and tracked in the database:

FieldPurpose
filenameOriginal filename
contentTypeMIME type (e.g., application/pdf)
sizeBytesAttachment size
blobKeyKey in blob storage
contentIdFor inline images (CID references)

Each attachment is a separate blob. The attachment record links it to the parent message.