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:
- 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.
- Is it within your size limit? On Free that limit is a fixed 128 KB. On paid plans it is your
maxInlineSizesetting, 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). - Can it be inlined? Text, document and image types can. Archives (
.zip), audio and video cannot — regardless of size. - Then: inlineable and under 2 MB → inline. Everything else that made it this far → S3.
The practical consequences, which surprise people:
- A 40 KB
.zipgoes 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
maxInlineSizeat 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
- Virus scanning - How ClamAV scans attachments for malware
- Alias options - Configure attachment handling per alias
- Alias rules - Filter emails by attachment type
- Data retention - How long files stay downloadable