Skip to content

tutorials

Storing email attachments in Cloudflare R2

July 20, 2026

#cloudflare-r2#email#attachments#storage#tutorial
Storing email attachments in Cloudflare R2
Visual: Luna Voss on okslop

You built an inbox on D1, it worked great, then someone forwarded a deck with three 8 MB PDFs and your message table started feeling it. Here is where the bytes actually belong.

If you are running email on Cloudflare, the bodies are easy: text and HTML are small, they live happily as columns in D1. Attachments are the part that bites you. A single thread can carry tens of megabytes of PDFs, images, and the occasional zip, and none of that belongs in a SQLite row. This is a walkthrough of doing attachment storage properly on Cloudflare: where the bytes go, how to key them, and how to serve them back without leaking your bucket to the world. We will build it by hand first so you understand every moving part, then show the version where you do not have to.

Where should email attachments be stored on Cloudflare?

Store the attachment bytes in Cloudflare R2 and keep only metadata in D1. R2 is object (blob) storage with no egress fees, designed for exactly this kind of opaque binary. D1 is a SQLite database, and SQLite reads the entire row when you touch any column in it, so a 10 MB PDF sitting in a body_blob column makes every query against that table drag, even queries that only wanted the subject line.

The split looks like this:

Lives in D1 (metadata)Lives in R2 (bytes)
filename, content_typethe raw attachment body
size_byteskeyed by content hash
r2_key (the pointer)nothing else
message_id (FK)

D1 stays small and fast. R2 holds the heavy stuff and only gets read when someone actually downloads the file. That separation is the whole game. Everything below is mechanics. Attachment storage is only one of the smart ways to use Cloudflare R2 in an email stack; the same bucket also earns its keep for raw-message archival and cold storage.

Step 1: Create and bind an R2 bucket

R2 buckets are created with Wrangler and bound to your Worker by name. Create one:

npx wrangler r2 bucket create my-app-attachments
Creating bucket 'my-app-attachments'...
✨ Created bucket 'my-app-attachments' with default storage class of Standard.

Then bind it in wrangler.jsonc so your Worker code can reach it as env.ATTACHMENTS:

{
  "name": "my-app",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-01",
  "r2_buckets": [
    {
      "binding": "ATTACHMENTS",
      "bucket_name": "my-app-attachments",
    },
  ],
}

If you forget the binding and try to use it, you get this at runtime, which is worth recognizing on sight:

TypeError: Cannot read properties of undefined (reading 'put')
  at handleAttachment (src/index.ts:42)

That is env.ATTACHMENTS being undefined. Add the r2_buckets binding to wrangler.jsonc and redeploy.

Step 2: Extract attachments from the MIME message

Inbound email arrives as a raw MIME message, and attachments are MIME parts with a Content-Disposition: attachment header. You have to parse the message and walk its parts. Pulling files out of MIME has real edge cases (nested multiparts, base64 vs quoted-printable encodings, inline images that are technically attachments), so use a parser rather than regexing headers by hand. Here we use postal-mime, which runs on the Workers runtime:

import PostalMime from "postal-mime";

type ExtractedAttachment = {
  filename: string;
  contentType: string;
  bytes: Uint8Array;
};

async function extractAttachments(rawEmail: ArrayBuffer): Promise<ExtractedAttachment[]> {
  const parsed = await PostalMime.parse(rawEmail);

  return parsed.attachments.map((att) => ({
    // Fall back to a generated name; some parts omit a filename.
    filename: att.filename ?? `attachment-${crypto.randomUUID()}`,
    contentType: att.mimeType ?? "application/octet-stream",
    bytes: new Uint8Array(att.content as ArrayBuffer),
  }));
}

Two things people get wrong here. First, filename can be missing or contain RFC 2047 encoded-word garbage like =?UTF-8?B?...?=; a good parser decodes it, but always have a fallback. Second, do not trust the filename for anything security-sensitive: never use it as a filesystem path or as the storage key. The key comes from the bytes, which is the next step.

Step 3: Hash the bytes and write to R2

Key the R2 object by a SHA-256 hash of its content. This is called content addressing, and it gives you free deduplication: if the same PDF is forwarded across five emails, all five metadata rows point at one R2 object and you store the bytes once. It also makes writes idempotent, since re-processing the same message produces the same key.

async function sha256Hex(bytes: Uint8Array): Promise<string> {
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  return [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

async function storeAttachment(
  env: Env,
  att: ExtractedAttachment,
): Promise<{ key: string; size: number }> {
  const hash = await sha256Hex(att.bytes);
  // Shard by the first two hex chars so no single R2 "directory" gets huge.
  const key = `att/${hash.slice(0, 2)}/${hash}`;

  // Skip the upload if we already have these exact bytes.
  const existing = await env.ATTACHMENTS.head(key);
  if (!existing) {
    await env.ATTACHMENTS.put(key, att.bytes, {
      httpMetadata: { contentType: att.contentType },
    });
  }

  return { key, size: att.bytes.byteLength };
}

The head() call before put() is what makes dedupe real: head() is cheap (it reads metadata, not the body) and lets you skip transferring bytes you already have. The two-character shard prefix (att/3f/3fa9...) keeps any single key prefix from growing unbounded, which matters once you have millions of objects and want to list or lifecycle them.

Step 4: Store metadata in D1

Now record the pointer. The D1 row links a message to the attachment without holding any bytes:

CREATE TABLE attachments (
  id           TEXT PRIMARY KEY,
  message_id   TEXT NOT NULL REFERENCES messages(id),
  filename     TEXT NOT NULL,
  content_type TEXT NOT NULL,
  size_bytes   INTEGER NOT NULL,
  r2_key       TEXT NOT NULL,
  created_at   INTEGER NOT NULL DEFAULT (unixepoch())
);

CREATE INDEX attachments_message_idx ON attachments (message_id);
CREATE INDEX attachments_r2_key_idx  ON attachments (r2_key);

The insert ties the three steps together:

async function recordAttachment(
  env: Env,
  messageId: string,
  att: ExtractedAttachment,
  stored: { key: string; size: number },
): Promise<void> {
  await env.DB.prepare(
    `INSERT INTO attachments
       (id, message_id, filename, content_type, size_bytes, r2_key)
     VALUES (?, ?, ?, ?, ?, ?)`,
  )
    .bind(
      crypto.randomUUID(),
      messageId,
      att.filename,
      att.contentType,
      stored.size,
      stored.key,
    )
    .run();
}

Note the r2_key index. Because keys are content-addressed and shared, you will want to know how many messages reference a given blob before you ever delete it from R2. Garbage collection of orphaned objects is a real chore in a content-addressed store, and it is the kind of thing that is easy to get subtly wrong (delete a blob that another message still points to and you have data loss). More on that at the end.

Putting the inbound handler together:

export default {
  async email(message: ForwardableEmailMessage, env: Env): Promise<void> {
    const raw = await new Response(message.raw).arrayBuffer();
    const messageId = await persistMessage(env, message); // your existing logic
    const attachments = await extractAttachments(raw);

    for (const att of attachments) {
      const stored = await storeAttachment(env, att);
      await recordAttachment(env, messageId, att, stored);
    }
  },
};

How do I serve a private R2 attachment without exposing the bucket?

Never make the bucket public. Serve downloads either through a short-lived presigned URL or through an authenticated Worker proxy. Both keep the bucket private; they differ in who streams the bytes.

Option A: Proxy through an authenticated Worker route

The Worker checks the request is allowed, reads the object from the bound bucket, and streams it back. The client never sees R2 credentials or the bucket name.

async function serveAttachment(req: Request, env: Env): Promise<Response> {
  const attachmentId = new URL(req.url).pathname.split("/").pop()!;

  // 1. Authorize: is this user allowed to see this attachment?
  const session = await authenticate(req, env);
  if (!session) return new Response("Unauthorized", { status: 401 });

  // 2. Look up the pointer (and check ownership in the same query).
  const row = await env.DB.prepare(
    `SELECT a.r2_key, a.filename, a.content_type
       FROM attachments a
       JOIN messages m ON m.id = a.message_id
      WHERE a.id = ? AND m.account_id = ?`,
  )
    .bind(attachmentId, session.accountId)
    .first<{ r2_key: string; filename: string; content_type: string }>();

  if (!row) return new Response("Not found", { status: 404 });

  // 3. Stream the bytes from R2.
  const object = await env.ATTACHMENTS.get(row.r2_key);
  if (!object) return new Response("Gone", { status: 410 });

  return new Response(object.body, {
    headers: {
      "content-type": row.content_type,
      "content-disposition": `attachment; filename="${row.filename}"`,
    },
  });
}

The authorization check joins through messages so a user can only ever fetch an attachment that belongs to a message in their own account. This is the most common bug in homegrown attachment serving: people authorize by attachment ID alone and let anyone with a valid session enumerate IDs. Always tie the lookup back to ownership.

Option B: Presigned S3 URL (offload the bytes to R2)

If you would rather not stream large files through your Worker (Workers have CPU and response-size considerations), hand the client a presigned URL via R2's S3-compatible API. The URL expires, so it cannot be shared indefinitely:

import { AwsClient } from "aws4fetch";

async function presignAttachment(env: Env, r2Key: string): Promise<string> {
  const client = new AwsClient({
    accessKeyId: env.R2_ACCESS_KEY_ID,
    secretAccessKey: env.R2_SECRET_ACCESS_KEY,
  });

  const endpoint =
    `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/my-app-attachments/${r2Key}`;

  // Sign a GET that is valid for 5 minutes.
  const signed = await client.sign(
    new Request(`${endpoint}?X-Amz-Expires=300`),
    { aws: { signQuery: true } },
  );

  return signed.url;
}

You still authorize the user before minting the URL; the difference is that the actual byte transfer happens directly between the client and R2. For a webmail inbox where users open big PDFs, Option B keeps that traffic off your Worker. For small images and tight access control, Option A is simpler. Many inboxes use both.

What about cleanup and orphaned objects?

Content addressing makes writes easy and deletes hard. Because multiple messages can point at one R2 object, you cannot delete the object when one message is deleted; you have to confirm no other row references that r2_key first:

async function maybeDeleteBlob(env: Env, r2Key: string): Promise<void> {
  const { count } = await env.DB.prepare(
    `SELECT COUNT(*) AS count FROM attachments WHERE r2_key = ?`,
  )
    .bind(r2Key)
    .first<{ count: number }>() ?? { count: 1 };

  // Only delete from R2 once the last reference is gone.
  if (count === 0) {
    await env.ATTACHMENTS.delete(r2Key);
  }
}

Run this after you delete the metadata row, not before, or you race yourself. In practice you also want a periodic sweep (a Cron Trigger) that lists R2 objects with no matching r2_key in D1 and removes them, because crashes between the D1 write and the R2 write leave both kinds of orphan. This is the unglamorous half of attachment storage that tutorials skip and production systems cannot.

Or skip all of this

Everything above is correct, and it is also about a day of careful work plus an ongoing maintenance surface: the MIME extraction edge cases, the dedupe and sharding, two retrieval modes, the ownership checks, and the orphan sweep. If you would rather not own that, this is exactly what Tinbox handles by default.

Tinbox runs on your own Cloudflare account, so the bytes still live in an R2 bucket you control: nothing is shipped off to a vendor. Inbound and outbound attachments are written to R2 keyed by content hash, metadata goes to D1, retrieval is signed, and dedupe and cleanup are handled for you. You get the architecture in this post without writing or maintaining it.

npx tinbox init yourdomain.com

That provisions the D1 database, the R2 bucket, and the Queues, wires the bindings, and stands up the inbox. Attachments land in R2 from the first email you receive.

Where to go from here

The attachment store is one piece of a Cloudflare-native inbox. The neighbors:

Deploy this yourself: npx tinbox init yourdomain.com. Full storage and binding details are in the docs.

Frequently asked questions

Where should I store email attachments on Cloudflare?

Put the attachment bytes in Cloudflare R2 and keep only metadata (filename, content type, size, and the R2 object key) in D1. R2 is object storage priced for blobs with no egress fees, while D1 is a SQLite database that should not hold multi-megabyte BLOBs. This keeps your database small and fast to query.

Why not store attachments directly in the D1 database?

D1 has a row size limit (a single SQLite row should stay well under a megabyte for performance) and reads pull the whole row, so a 10 MB PDF in a column slows every query that touches that table. D1 is also billed on rows read and storage in a way that makes large blobs expensive. R2 is built for exactly this and has no per-blob query cost.

How do I serve a private R2 attachment without making the bucket public?

Two options. Generate a short-lived presigned URL with the S3-compatible API (it expires, so the link cannot be shared forever), or proxy the download through a Worker that checks the user is authorized, reads the object from the bound R2 bucket, and streams it back. The proxy keeps zero credentials on the client.

How do I avoid storing the same attachment twice?

Key the R2 object by the SHA-256 hash of its bytes (content addressing). If two emails carry the same PDF, both metadata rows point at the same R2 key and you store the blob once. Check for the key before writing to skip the upload entirely.

Does Tinbox store attachments in R2 automatically?

Yes. Tinbox runs on your own Cloudflare account and writes inbound and outbound attachment bodies to R2 by default, keying them by content hash, while message metadata lives in D1. You get signed retrieval and dedupe without wiring any of it yourself.