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, which defaults to 1 MB and can be moved 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. If you leave maxInlineSize at its 1 MB default, nothing will ever be offloaded by size — a 1.5 MB PDF is over your limit and gets excluded, not stored. Raise the slider above 2 MB if you want large files stored rather than dropped.

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:

  • Downloads are counted — you can see in the logs what was actually fetched.
  • Access can be revoked — killing the file kills the link, 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. When the email is purged at the end of its retention period, the attachment goes with it and the URL starts returning 404.

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.

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 optionally a custom endpoint for non-AWS providers.

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

Connections allowed per plan: Free 0 · Maker 1 · Business 3 · Platform unlimited.

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 1MB. 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, disabled-in-alias-settings) and sometimes a full human-readable sentence. 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 and the rest are dropped silently — there is no marker in the payload, and attachments.length will simply be 20.

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 CID for inline images referenced from the HTML body
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 1 MB, range 0.1–10 MB 2 MB Yes
Business maxInlineSize: default 1 MB, range 0.1–10 MB 2 MB Yes
Platform maxInlineSize: default 1 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