Email Attachments
Attach files to outbound emails. SES v2 handles delivery via raw MIME.
Request
POST /api/v1/emails
{
"from": "hi@yourdomain.com",
"to": ["user@example.com"],
"subject": "Invoice",
"html": "<p>See attached.</p>",
"attachments": [
{
"filename": "invoice.pdf",
"content": "<base64-encoded-bytes>",
"content_type": "application/pdf"
}
]
}
Rules
| Field | Rules |
|---|---|
filename |
1–255 chars |
content |
base64. Raw bytes decoded server-side, then re-encoded into MIME. Wins over url when both are given. |
url |
https:// only, fetched at send time (public addresses only, no redirects, 10 s timeout). A url the caller must fix (a 4xx answer other than 429, a redirect, an empty body, a private address, not https) fails POST /v1/emails with 422 ATTACHMENT_FETCH_FAILED and stores a failed message row; the SDKs do not retry it. A host-side failure (a 5xx or 429, a timeout, an unresolvable host) stays a retryable 500. Until 2026-11 this was a generic 500. |
content_type |
Defaults to application/octet-stream. |
| Max count | 10 per email |
Implementation
Source: src/lib/providers/email.ts. When attachments.length > 0, the send path switches from SendEmailCommand(Simple) to SendEmailCommand(Raw) and builds a multipart MIME body with buildRawMime():
- Outer boundary:
multipart/mixed. - Inner
multipart/alternativefor text + html. - Each attachment as a separate part with
Content-Disposition: attachment.
Size
SES has a 10 MB raw message cap — base64 encoding inflates ~33%, so Sendoka caps each email at 7 MB of attachment bytes before encoding, inline and url combined.
| Limit | Value | Over it |
|---|---|---|
One url attachment |
5 MB | 422 ATTACHMENT_FETCH_FAILED on /v1/emails (a 500 until 2026-11), PROVIDER_ERROR item in a batch |
| All attachments of one email | 7 MB | 413 ATTACHMENT_TOO_LARGE — inline is checked before sending; url bytes once fetched |
All url attachments of one request |
40 MB | /v1/emails/batch: the items whose urls would exceed it fail as PROVIDER_ERROR naming the request budget; the others still send |
url attachments are fetched one at a time, in order. The request budget counts bytes actually downloaded: a url whose response declares a Content-Length holds that much when its headers arrive (refused at once if the budget cannot cover it), and one without holds what it may still grow to (its per-url cap, or what is left of the budget) and is trued up to the bytes that arrived. Batch items fetch concurrently, so many small files that declare their size never exhaust the budget early, and the bytes pulled across one request never exceed 40 MB. A download that breaks partway keeps the bytes it already pulled charged. The same url repeated across one request — a bulk send fans one body out to every recipient — is downloaded once and counts once against the request budget; a failed fetch is not reused, so the next item tries it again. A url that came back larger than the budget it was allowed still counts in full against the request budget, so retrying an oversized link within the same batch does not reset it. A url is not a way past the 7 MB ceiling — split the content across emails or link to it instead.
Test mode
Test keys skip the SES call entirely. Attachments are accepted by validation, stored on the messages row (currently only the first toEmail is stored), but no MIME is built.