Attachment processing

EmailConnect delivers attachments in one of two ways: inline (Base64 in the webhook payload) or offloaded to S3 (the payload carries a download URL instead). You do not pick per attachment — we pick per attachment, based on its size, its type, and your plan.

The one rule that decides everything

For every attachment, in order:

  1. Is the whole email under 10 MB? The raw email (all attachments plus headers and body) has a hard 10 MB ceiling. Over that, the email is rejected at the door — it never becomes a webhook call at all. Nothing above 10 MB ever reaches storage.
  2. Is it within your size limit? On Free that limit is a fixed 128 KB. On paid plans it is your maxInlineSize setting (the maximum attachment size slider in Settings → Storage), which defaults to 10 MB and can be lowered anywhere in the 0.1–10 MB range. Over your limit, the attachment is excluded from the payload (the email still arrives — see Excluded attachments).
  3. Can it be inlined? Text, document and image types can. Archives (.zip), audio and video cannot — regardless of size.
  4. Then: inlineable and under 2 MB → inline. Everything else that made it this far → S3.

The practical consequences, which surprise people:

  • A 40 KB .zip goes to S3, not inline. Size is not the only trigger — type is.
  • S3 offloading starts at Maker. On Free there is no offload path at all: anything over 128 KB, or of a non-inlineable type, is simply excluded.
  • The 2 MB inline boundary is fixed, but your acceptance limit is not. At the 10 MB default, an inlineable file under 2 MB is inlined and anything from 2 MB up to 10 MB is offloaded to S3. If you lower the slider below 2 MB, nothing is offloaded by size any more: a file over your limit is excluded, not stored. Despite its name, maxInlineSize is the largest attachment we accept, not the largest we inline.

Inline delivery

The file is Base64-encoded into the webhook JSON. Your handler decodes it — no second HTTP request, no expiry, nothing to fetch.

{
  "attachments": [
    {
      "filename": "invoice.pdf",
      "contentType": "application/pdf",
      "size": 125840,
      "content": "JVBERi0xLjQKJeHp69MKMSAwIG9iago8PC9UeX...",
      "status": "included",
      "storage": "inline",
      "virusScan": { "status": "clean" }
    }
  ]
}

Applies to: inlineable types under 2 MB on paid plans, under 128 KB on Free.

Trade-off: it inflates the payload by ~33% (Base64 overhead), and some webhook receivers cap request body size well below 2 MB.

S3 delivery

The file is uploaded to storage and the payload carries a downloadUrl instead of content.

{
  "attachments": [
    {
      "filename": "archive.zip",
      "contentType": "application/zip",
      "size": 40960,
      "downloadUrl": "https://app.emailconnect.eu/attachments/{fileId}/download",
      "status": "completed",
      "storage": "s3",
      "uploadType": "sync",
      "virusScan": { "status": "clean" }
    }
  ]
}

Applies to (Maker and above): any non-inlineable type at any accepted size, plus inlineable files between 2 MB and your maxInlineSize limit.

The download URL points at us, on purpose

downloadUrl is always an EmailConnect URL — https://app.emailconnect.eu/attachments/{fileId}/download. It is not a presigned S3 link, and we never hand you a bucket URL.

Fetch it and we 302-redirect you to a short-lived signed URL that we mint at that moment. This is deliberate, and it is the point of the design:

  • Access ends with the email — when the email is deleted, by retention or by you, the link stops working, immediately, everywhere.
  • Data stays in-region — the redirect is issued from EU infrastructure, so residency guarantees survive the download.

Follow redirects (every HTTP client does by default) and it just works.

How long the URL lives

As long as your retention window — no longer, and no shorter. There is no separate URL expiry to plan around: a stored file expires together with its email. Once the retention window has passed the URL answers 410 Gone, and after the email has been purged, 404.

Fetch the file and keep your own copy; do not hot-link the URL. It is meant for your integration to download from, not for end users or web pages:

  • it stops working when the retention window ends,
  • it is rate-limited to 100 requests per 15 minutes per file and client IP address, and
  • the signed URL we redirect you to is valid for one hour, so request downloadUrl again rather than storing the redirect target.

Retention is 1 hour on Free and Maker. Business and Platform configure it (up to 30 days and 365 days respectively). If you are on a short window, fetch the file when the webhook arrives — do not queue it for tomorrow. See Data retention.

If status is pending, the upload was still finishing when we delivered the webhook (uploadType: "async"). The URL may briefly return 202 — retry in a few seconds.

Checking whether a file was fetched

In Logs, click Result on a message to see its delivery details. The Files card lists each file stored for that message, how often its downloadUrl was fetched and when last, and the storage it sits in. Attachments delivered inline in the payload are listed too, marked Inline, and can be downloaded from the card.

The count is how often the link was requested, not how many transfers completed. Downloading a file from the card itself is not counted, so checking a file yourself does not change the number.

Every download is recorded in the audit log (Logs, Activity tab, on the Platform plan), whether it came through the downloadUrl or from the card: when, from which IP address and user agent, and, for a download from the card, which account. Files are always saved, never opened in the browser.

Custom S3 storage

You can point a storage connection at your own S3-compatible bucket, so the bytes land in infrastructure you own.

Required: bucket name, region, access key ID, secret access key, and a custom endpoint for non-AWS providers. Leave the endpoint empty for AWS S3.

Editing a connection's bucket, region or endpoint changes where new files go; its connection test resets, so test it again after the change. Files stored before the edit stay where they are and are deleted from there when they expire.

Compatible with: AWS S3, MinIO, Backblaze B2, DigitalOcean Spaces, Cloudflare R2.

Storage per plan: Free has none (attachments are inline only). Maker uses our managed storage. Business has our managed storage plus up to 2 buckets of your own. Platform has as many as you need.

The payload does not change. Even with your own bucket, the webhook still carries our downloadUrl — the same tracked, revocable, in-region link described above. The bucket key is stored on our side and is never exposed in the payload; there is no s3Key field. You have the file in your bucket and can address it however you like with your own tooling; the webhook contract stays identical whether you use managed or custom storage.

Excluded attachments

An attachment that cannot be delivered does not silently vanish. It appears in the payload with excluded: true and a reason:

{
  "filename": "recording.mov",
  "contentType": "video/quicktime",
  "size": 8400000,
  "excluded": true,
  "excludeReason": "File size 8.01MB exceeds your attachment limit of 5MB. Adjust in Settings → Storage."
}

The email itself is still delivered. Common reasons: over your size limit, a type your alias rules block, virus detected, or (on Free) any file over 128 KB or of a non-inlineable type.

excludeReason is not a closed set of values — it is sometimes a slug (virus-detected, too-large, too-many-attachments, disabled-in-alias-settings, data-residency-no-own-storage) and sometimes a full human-readable sentence. Under Data Residency Mode, a file that would have to be stored on EmailConnect storage is not stored and carries data-residency-no-own-storage. Match on a prefix; never compare it for equality.

The 20-attachment cap

A maximum of 20 attachments per email are delivered. If an email arrives with more, the first 20 are processed as usual and each of the rest still appears in the payload, as an entry with no file:

{
  "filename": "photo-21.jpg",
  "contentType": "image/jpeg",
  "size": 184320,
  "excluded": true,
  "excludeReason": "too-many-attachments"
}

So attachments.length tells you how many files the email carried, and the entries marked too-many-attachments tell you which ones you did not receive.

If you are ingesting mail that may carry more than 20 files, this is the limit to design around: it is a hard cap, not a plan limit, and raising your plan does not raise it.

Attachment fields

Field Description
filename Original file name
contentType MIME type (e.g. application/pdf)
size Size in bytes
contentId The Content-ID the sender gave the file, without angle brackets, or null when there is none. Present on every entry, whether the file was inlined, stored or excluded. An HTML body refers to a file as cid:<contentId>. A Content-ID alone does not mean the body embeds the file: some mail clients give ordinary attachments one too
content Base64 content — inline delivery only
downloadUrl Tracked EmailConnect download link — S3 delivery only
status included · completed · pending · failed · rejected · unknown
storage inline · s3
uploadType sync (URL ready now) · async (may 202 briefly)
excluded / excludeReason Set when the file was not delivered
metadata Hash, image dimensions, PDF page count (Maker+)
virusScan Per-file scan result (Business+)

Size limits at a glance

Plan Accepted attachment size Inline up to S3 offload
Free 128 KB (fixed) 128 KB No — larger files are excluded
Maker maxInlineSize: default 10 MB, range 0.1–10 MB 2 MB Yes
Business maxInlineSize: default 10 MB, range 0.1–10 MB 2 MB Yes
Platform maxInlineSize: default 10 MB, range 0.1–10 MB 2 MB Yes

Above all of this sits the 10 MB whole-email ceiling. An email whose total raw size exceeds it is rejected outright and the sender gets an error — no webhook fires.

Security

Virus scanning

Available on Business+, and only when enabled in your settings. Clean files are delivered with scan metadata; infected files are excluded (excludeReason: "virus-detected") and carry no downloadUrl.

See Virus scanning for definition updates and EICAR testing.

File type filtering

Use alias rules to block file types you never want to receive — executables (.exe, .bat, .cmd), scripts (.js, .vbs, .ps1), or archives.

Content validation

We scan for malware; we do not vouch for content. Validate and sanitize anything you decode or download before you process it.

Working with attachments

Decoding inline content

// Node.js
const buffer = Buffer.from(attachment.content, 'base64');
fs.writeFileSync(attachment.filename, buffer);
# Python
import base64

content = base64.b64decode(attachment['content'])
with open(attachment['filename'], 'wb') as f:
    f.write(content)

Fetching an offloaded file

// Node.js — fetch follows the 302 to the signed URL automatically
const response = await fetch(attachment.downloadUrl);
const buffer = await response.arrayBuffer();
# Python — requests follows redirects by default
import requests

response = requests.get(attachment['downloadUrl'])
content = response.content

Handling both in one branch

for (const att of payload.message.attachments) {
  if (att.excluded) { logSkipped(att.filename, att.excludeReason); continue; }
  const bytes = att.content
    ? Buffer.from(att.content, 'base64')
    : Buffer.from(await (await fetch(att.downloadUrl)).arrayBuffer());
  await store(att.filename, bytes);
}

Do this and you never have to care which path an attachment took.

Related topics