# Attachment processing

> How EmailConnect handles email attachments and delivers them to your webhooks

Source: https://emailconnect.eu/help/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](#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.

```json
{
  "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`.

```json
{
  "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](https://emailconnect.eu/help/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:

```json
{
  "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](https://emailconnect.eu/help/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:

```json
{
  "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](https://emailconnect.eu/help/virus-scanning/) for definition updates and EICAR testing.

### File type filtering

Use [alias rules](https://emailconnect.eu/help/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

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

```python
# Python
import base64

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

### Fetching an offloaded file

```javascript
// Node.js — fetch follows the 302 to the signed URL automatically
const response = await fetch(attachment.downloadUrl);
const buffer = await response.arrayBuffer();
```

```python
# Python — requests follows redirects by default
import requests

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

### Handling both in one branch

```javascript
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](https://emailconnect.eu/help/virus-scanning/) - How ClamAV scans attachments for malware
- [Alias options](https://emailconnect.eu/help/alias-options-explained/) - Configure attachment handling per alias
- [Alias rules](https://emailconnect.eu/help/alias-rules/) - Filter emails by attachment type
- [Data retention](https://emailconnect.eu/help/data-retention/) - How long files stay downloadable
</content>
</invoke>
