File attachments
[[endpoints]]path = "/job-application"# ... endpoint config ...
[endpoints.attachments]allowed_types = ["application/pdf", "image/*"] # required, no defaultmax_count = 5 # defaultmax_total_size = "10MB" # defaultJudged by bytes, not by name
Section titled “Judged by bytes, not by name”allowed_types has no default — enabling attachments means naming what you accept. Enforcement runs against the sniffed content type of the actual file bytes; the client-declared type and the filename extension are ignored for authorization. A shell script named photo.png is rejected as the text/plain it actually is.
Two consequences worth knowing:
- Wildcards match sniffed families:
image/*covers PNG, JPEG, GIF, WebP. - Only sniffable types are usable. Text-family formats (CSV, plain
.txt) all sniff astext/plain— allowtext/plainto accept them, knowing you can’t distinguish among them. Unrecognizable binaries sniff asapplication/octet-stream.
Violations return 422 with per-file reasons; nothing sends.
Form mode
Section titled “Form mode”Add <input type="file" name="cv"> to your form (any field name; enctype="multipart/form-data" required). Files within policy ride along to the transport; the outbound attachment’s content type is the sniffed value, and filenames are sanitized (path components and control characters stripped).
API mode
Section titled “API mode”Attachments are a structural attachments array — base64 data, never a template field:
{ "email": "customer@example.com", "message": "Your license is attached.", "attachments": [ {"filename": "license.pdf", "data": "<base64>"} ]}content_type is accepted but ignored — the sniffed type wins. Sending attachments to an endpoint without the opt-in block returns 422 rather than silently dropping your files.
Sizing interplay
Section titled “Sizing interplay”max_body_size must accommodate the attachment budget. On attachment endpoints, an unset max_body_size defaults to max_total_size + 1MB of form headroom; an explicit value smaller than max_total_size is a config error. With storage enabled, queued sends persist attachments as blobs — factor them into storage.max_size.
Delivery
Section titled “Delivery”All five mail transports carry attachments natively (Postmark/Resend/SES JSON fields, Mailgun multipart, outbound-SMTP multipart/mixed), and the webhook transport’s receivers are unaffected (attachments do not cross into webhook payloads in v2.0). The SMTP listener does not ingest attachments from relayed mail — open an issue if you have an app that needs that.
Mind your provider’s own limits (Postmark 10MB total, Mailgun 25MB, SES 40MB raw): Posthorn enforces your configured budget, and the provider enforces theirs at send time.