MailFlatDocs
Documentation/Common test cases/Attachments

Testing email attachments

Files arrive as metadata you can assert on and download from one endpoint, so a PDF invoice test is two calls, not a MIME parser. Sending files works too, so a test can make its own fixture.

How it works

Attachments work both ways. Incoming mail is unpacked for you: each attached file lands in the attachments array with its filename, MIME type and size. The list carries metadata only, because a message with one 5 MB file would otherwise make every inbox listing 5 MB heavier. The bytes live behind their own endpoint and come back exactly as the sender encoded them. Going the other way, send takes files too, so a test can produce its own fixture instead of waiting for someone to mail one in.

See what arrived

Every SDK types this now. msg.attachments gives you filename, content type and size, and .download() fetches the bytes. The raw payload below is still there for fields the type does not model.
msg = inbox.wait_for_message(timeout=30)
att = msg.raw["attachments"][0]
assert att["filename"] == "invoice.pdf"
assert att["content_type"] == "application/pdf"

Download the file

One GET, and the response body is the file itself, the same bytes the sender attached, with the original Content-Type.
import os, requests
 
url = (f"https://mailflat.net/api/v1/inboxes/{inbox.address}"
f"/messages/{msg.raw['id']}/attachments/{att['id']}")
pdf = requests.get(url, headers={"X-API-Key": os.environ["MAILFLAT_API_KEY"]}).content
assert pdf.startswith(b"%PDF")

Limits, in both directions

Incoming: a single file over 5 MB, or more than 10 MB across one message, is recorded but not stored. The entry stays so you can tell an oversized file apart from one that never arrived. Outgoing is different, because it depends on your plan: free is deliberately small (a real test PDF fits, a real invoice does not) and Pro is far larger. Read the numbers from GET /api/plans rather than copying them into your code. They are published there precisely so they can change without breaking you. Going over is rejected with the limit spelled out, never silently dropped.
curl
curl -s "$MAILFLAT_API/inboxes/$ADDRESS/latest" \
-H "X-API-Key: $MAILFLAT_API_KEY" | jq '.email.attachments[] | {filename, truncated}'
# { "filename": "backup.zip", "truncated": true }
 
# Downloading a truncated attachment answers plainly rather than sending 0 bytes:
# { "detail": "Attachment was too large to store" }
 
# What YOUR plan allows when sending. Single source, no login needed:
curl -s https://mailflat.net/api/plans \
| jq '.plans[] | {plan: .id, max_attachment_bytes, max_attachments}'

Send a file, then read it back

The other direction closes the loop: attach a file, let it deliver, and assert the bytes that came out match the ones that went in. The SDKs do the base64 for you. Python and Java take a path straight off disk, JavaScript takes the bytes instead (it also runs in browsers, where there is no filesystem). Send returns once the mail is accepted, so wait_until_sent before you go looking for it on the other side.
sent = sender.send(receiver.address, subject="Invoice",
attachments=["invoice.pdf"])
sender.wait_until_sent(sent["message_id"], timeout=120)
 
got = receiver.wait_for_message(timeout=60)
assert got.raw["attachments"][0]["filename"] == "invoice.pdf"

Worth knowing

Encrypted inboxes hand back an envelope, not a file
On an end-to-end encrypted inbox the server cannot read the file, so the download endpoint returns the encrypted envelope and your own key has to open it. Filename, type and size stay readable. They sit at the same level as the sender address, which is worth knowing before you attach something whose name gives it away.