Receiving Email in Your App — Why You Can't Run a Mail Server Here

·6 min read·Updated
emailwebhooksimaparchitecturehow-toworkers

Turning email into something your app can act on — a support address that posts into Discord, replies that close a ticket, forwarded receipts that become rows in a database — is a genuinely useful thing to build. You can't do it by running your own mail server here, because inbound mail delivery requires port 25 and a TCP binding gets a high, platform-assigned port number instead. Two approaches work: have an email provider parse incoming mail and POST it to your app as a webhook, or poll an existing mailbox over IMAP. The second is a good fit for a small container that runs continuously.

Why a mail server isn't on the table

Worth understanding precisely, because it also explains what the platform is good at.

When you bind a TCP or UDP port, you get a public endpoint on a platform host at a high port number assigned by the platform — not the container port you chose, and not one you pick. Sending mail servers connect to port 25. They do not read an SRV record, they do not follow a redirect, and there's nowhere to tell them otherwise. So an MX record cannot point at your app.

HTTPS bindings are different, and it's the asymmetry that matters: those are routed by hostname on 443 with a certificate handled for you. You get one very good inbound door, and it speaks HTTP.

That's the shape of the platform — ports and URLs work well for HTTP and awkwardly for anything else. Which is fine, because both approaches below arrive over HTTP or leave over TLS.

Option A — let a provider parse it and call your webhook

The standard answer. Transactional email providers offer an inbound parse feature: you point an MX record for a domain or subdomain at them, they receive the mail, parse it into JSON, and POST it to a URL you nominate.

Your app ends up being a plain webhook receiver — the thing an HTTPS binding is genuinely good at.

What to get right:

  • Verify the request is really from the provider. An inbound-email endpoint is a public URL that accepts structured data and probably triggers work. Anyone can POST to it. Use the provider's signing mechanism, or at minimum a long shared secret in a header — the same reasoning as verifying a Telegram webhook.
  • Answer quickly. Providers expect a fast 200 and will retry if they don't get one. Do the parsing and store the message, then acknowledge; do the slow part afterwards.
  • Expect retries, so deduplicate. A retried delivery is a duplicate ticket if you don't check. The Message-ID header is the natural key.

This is the right choice when the address is for your app — support@, receipts@, inbound@ — and when you want the mail to arrive as an event rather than sitting somewhere.

Option B — poll a mailbox over IMAP

Less fashionable and often better, especially if the mailbox already exists.

Your app connects out to an ordinary mailbox — the one your team already reads — checks for unread messages on a timer, processes them, and marks them seen. No MX record, no DNS change, no inbound port at all.

Why this fits here particularly well:

  • No listener means no attack surface. The app makes only outbound connections. Nothing to authenticate, nothing to scan.
  • It's idle almost all the time, which is exactly the workload a small container is built for.
  • It doesn't disturb the humans. The mailbox keeps working as a mailbox; your app is just another client reading it.
  • No provider lock-in. IMAP is IMAP.

Run it as a loop in your existing app rather than a second application — the same argument as any other background work, and it means the job can reach your database.

The trade-offs are real: you're polling, so there's latency between arrival and action; you need to handle the connection dropping and reconnect; and you'll want an app password rather than the account password, stored as an environment variable.

Choose Option B when the mailbox is a human inbox you're automating. Choose Option A when the address exists only so software can receive things.

The parts both options share

Track what you've processed, on a volume. This is the failure specific to containers. If "seen" lives in memory, your container is recreated on the next deploy and you reprocess everything — which for a support inbox means a burst of duplicate tickets, and for a Discord relay means reposting a week of mail into a channel at once. Keep processed message IDs in a table on a volume, the same way an RSS bot tracks what it has already posted.

Be careful with attachments. This is where a small container actually bites. A single message with a 20 MB attachment, decoded into memory alongside your normal working set, is a meaningful fraction of a 0.25 GB allocation — and two at once is an out-of-memory kill. Decide deliberately:

  • Ignore attachments entirely, if you only need the text. Most inbound-email automations do.
  • Stream them to disk rather than buffering, and cap the size you accept.
  • Remember that anything you keep counts toward your account's disk limit, which is account-wide.

Treat the content as hostile. Anyone can send you an email. The body is user input from a stranger — escape it before rendering, don't trust the From header as identity, and if you feed it to a model, that's prompt injection with an open door.

Store less than arrives. Full message bodies grow quickly and are rarely needed after processing. Extract what you actually use and keep a reference.

Replying is a separate problem

Receiving and sending are independent. If your app answers, it sends through an API in the normal way — sending email is its own set of decisions, mostly about domain authentication.

The one thing worth doing at the seam: preserve threading. Keep the incoming Message-ID and set In-Reply-To on your reply. It costs one line and it's the difference between a conversation and a series of unrelated emails in someone's client.

FAQ

Can I run a mail server on JustRunMy.App?

No. Inbound mail needs port 25, and TCP bindings receive a high platform-assigned port instead, so an MX record can't reach your app.

How do I receive email in my app then?

Either use a provider's inbound parsing to POST messages to an HTTPS webhook, or poll an existing mailbox over IMAP from your app.

Which is better?

IMAP polling if you're automating a mailbox people already use. Inbound parsing if the address exists purely for software.

Do I need an inbound port for IMAP?

No. Your app connects outward, so it needs no port binding at all — which is also why it has no attack surface.

Why does my app reprocess old email after a deploy?

Its record of what it has seen is in memory, and the container is recreated on every deploy. Persist processed message IDs on a volume.

What about attachments?

Cap or skip them. A 20 MB attachment decoded in memory is a large share of a small container's RAM, and anything you keep counts toward the account disk limit.

Wrapping up

"Can I receive email?" turns out to be two different questions wearing one hat. If you meant running a mail server, no — and the port number is only the first reason. If you meant getting email into your application, that's straightforward, and the version that fits this platform best is the least ambitious one: a small process that connects out to a mailbox, reads what's new, writes down what it handled, and goes back to sleep. No inbound port, no DNS changes, nothing exposed. It's the same shape as a bot, which is the shape this platform is built around.