Your App Can Receive Email Too: Build an Email-to-Ticket Pipeline with TypeScript
Most application email tutorials stop after sendEmail(). That covers password resets, receipts, and alerts. But email can also be an input to your application: a message sent to [email protected] can create a
Most application email tutorials stop after sendEmail().
That covers password resets, receipts, and alerts. But email can also be an input to your application: a message sent to [email protected] can create a ticket, and a reply can join the same conversation. People already know how to send a message, attach a file, and reply. Your application can meet them in that workflow.
To explore that idea, this post walks through Mailroom, a small support inbox you can run and adapt. SMTPfast receives and parses the email; the TypeScript app turns it into a ticket that an agent can answer in the browser. Support tickets give us a concrete example, but the same approach can handle emailed bug reports, document submissions, or replies to notifications in a SaaS app.
Mailroom uses Fastify for its HTTP routes, PostgreSQL for tickets and pending work, and SMTPfast for inbound email and threaded replies. A server-rendered inbox makes the conversation visible without needing a separate frontend application.
The app runs locally and checks SMTPfast for new messages. You can test the complete conversation without deploying a server or setting up an HTTPS tunnel.
Source code: SMTPfast Email-to-Ticket on GitHub.
If you have built a Node.js API and are comfortable with basic TypeScript, HTTP requests, and SQL, you have the background needed to follow along. No prior experience with receiving email is assumed. To run the example, you will need Node.js, Docker Compose, a domain you can configure, and an SMTPfast plan that includes inbound email.
For the runnable demo, follow the setup and test the email conversation. Continue with the implementation sections to understand queues, threading, and safe rendering. The webhook section is an optional extension for a deployed app.
What happens when an email arrives?
SMTPfast accepts the message, scans it, parses it, and exposes it through the receiving API. Mailroom can discover it by polling-asking the API for new messages at regular intervals-or by receiving a webhook, an HTTP notification SMTPfast sends to our app. Both paths supply metadata and a message ID; the app retrieves the content separately.
The default local mode polls for message metadata and inserts unseen SMTPfast IDs into PostgreSQL. It needs no public URL. The optional webhook handler verifies the request signature, inserts the same kind of job, and responds with 202 Accepted.
A worker is the background code that processes pending jobs. It retrieves the complete message, finds or creates its ticket, and stores the content the UI can display safely. The support UI reads those stored tickets. When an agent replies, the app calls SMTPfast's reply endpoint for the received message.
The durable queue separates message discovery from full-message processing and retries. In webhook mode, the handler can acknowledge an event without waiting for the receiving API.
Configure an inbound address
Inbound email is available on SMTPfast's paid plans. You will need a verified sending domain and access to its DNS records. Receiving can use that domain or a subdomain of it in the same SMTPfast team.
If example.com already uses Google Workspace, Microsoft 365, or another mailbox provider, do not replace its MX records. Use a dedicated subdomain instead:
inbound.example.com
Enable receiving for the subdomain in SMTPfast and publish the returned MX record at priority 10. Wait until its receiving status is active. SMTPfast needs to be the only MX at the lowest-numbered priority. Another MX at the same priority can receive part of the traffic; a lower-numbered record can receive all of it.
An MX record tells sending mail servers where to deliver email for that domain. You can manage it at your existing DNS provider.
You can then use [email protected] as the app's address. SMTPfast receives the mail; the app turns it into tickets. You do not need to create a Gmail mailbox or configure forwarding for that address.
Create an API key with only these scopes:
inbound:read
email:send
The worker needs inbound:read to retrieve received messages and mint attachment URLs. The UI needs email:send to reply. It does not need permission to manage domains, contacts, broadcasts, API keys, or received-message deletion.
That is enough for the local polling quickstart. You do not need a tunnel or webhook for the first run.
The complete DNS and receiving setup is in SMTPfast's inbound email documentation.
Set up the TypeScript application
Use Node.js 22 or 24, plus Docker Compose for PostgreSQL. Download or clone the companion repository linked above and open a terminal in its root directory. The snippets below explain the implementation; the repository contains the complete application, schema, templates, and tests.
Fastify handles the HTTP routes, pg connects to PostgreSQL, and Zod validates API responses and webhook payloads. The dependency versions are pinned by the repository's lockfile.
Install the dependencies and copy the example environment file:
npm install
cp .env.example .env
DATABASE_URL=postgres://tickets:tickets@localhost:55432/tickets
SMTPFAST_BASE_URL=https://smtpfa.st/api/v1
SMTPFAST_API_KEY=sf_live_replace_me
[email protected]
INGEST_MODE=poll
APP_SECRET=replace-with-at-least-32-random-characters
ADMIN_USERNAME=admin
ADMIN_PASSWORD=replace-with-a-long-random-password
ADMIN_USERNAME and ADMIN_PASSWORD are credentials you choose for Mailroom's ticket UI. They are separate from your SMTPfast account and API key. The browser asks for them when you open the inbox. Use a password of at least 12 characters.
The app uses HTTP Basic authentication to keep this tutorial focused. Plain HTTP is suitable for the localhost demo; a deployed app must use HTTPS to protect those credentials. A multi-user support product should use an identity provider, sessions, roles, and per-ticket authorization.
APP_SECRET signs ticket-bound form tokens; it is not an SMTPfast credential. If you later select INGEST_MODE=webhook, also add SMTPFAST_WEBHOOK_SECRET from the webhook configuration.
Generate a value for APP_SECRET and paste it into .env:
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
Start the database, apply the schema, and run the app:
docker compose up -d --wait postgres
npm run db:migrate
npm run dev
Open http://localhost:3000 and sign in with your chosen admin credentials. Send an email from your usual mailbox to INBOUND_ADDRESS. With the default five-second worker interval, it should appear shortly; refresh the inbox to see it. SMTPfast's receiving dashboard is a useful first check if it does not arrive.
With the app running, a round trip through your usual mailbox makes the flow easier to see. The implementation sections that follow explain what happens at each step.
Check the complete email conversation
Keep INGEST_MODE=poll and the app running:
- Send a new email with a distinctive subject from Gmail or another external mailbox to
INBOUND_ADDRESS. Refresh Mailroom after a worker cycle and open the new ticket. - Send a reply from Mailroom. Check the sender's inbox, including spam, for the response.
- Reply to that response from the external mailbox. Refresh Mailroom and confirm the answer appears on the same ticket.
Repeated worker cycles should leave the message count unchanged. This round trip shows both sides of the integration: incoming mail becomes application data, and an application reply returns to the email conversation.
If the first message is missing, check SMTPfast's received-mail dashboard. If it is there, confirm that INBOUND_ADDRESS matches its receiving address and check the app's worker logs. A missing response should be checked in SMTPfast's outbound delivery logs.
The sections below look inside that flow: how messages become durable work, how replies find their ticket, and how the UI handles untrusted email content. You can use them as a reference when adapting the example to your own application.
How polling discovers messages
The receiving list endpoint returns message metadata. The client validates that response and gives the worker the newest items:
async listReceivedEmails(limit = 100): Promise<ReceivedEmailSummary[]> {
const boundedLimit = Math.max(1, Math.min(100, limit));
const payload = await this.request(`/emails/receiving?limit=${boundedLimit}`);
return receivedEmailListSchema.parse(payload).data;
}
Each worker cycle turns previously unseen received IDs into the same durable jobs that webhook mode creates:
const messages = await mailClient.listReceivedEmails(100);
for (const message of [...messages].reverse()) {
await store.enqueueInboundJob(
`received:${message.id}`,
message.id,
{
id: `poll:${message.id}`,
type: "email.received",
timestamp: message.created_at,
data: message,
},
);
}
Polling is the tutorial default because it works on localhost. The unique event key makes repeated polls harmless. This demo reads only the newest 100 messages across the team, including mail received before the app started. If more than 100 messages arrive between polls, older ones can be missed. A production poller needs pagination and persisted checkpoints; this project's deployment option uses webhooks instead.
Queue notifications without duplicates
Each job needs a stable key so seeing the same notification again does not create more work. This is idempotency: repeating an operation has the same effect as doing it once. Polling uses received:<message ID>. Webhook mode uses the event ID, with a body hash as a fallback.
SMTPfast webhook events can carry a provider event ID. The inbound example also permits payloads without that top-level ID, so Mailroom falls back to a SHA-256 hash of the raw body:
function webhookEventKey(rawBody: Buffer, providerEventId?: string): string {
return providerEventId ??
`sha256:${createHash("sha256").update(rawBody).digest("hex")}`;
}
Retries use the same request body, making the hash stable. PostgreSQL enforces uniqueness:
CREATE TABLE inbound_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_key text NOT NULL UNIQUE,
smtpfast_received_id text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
available_at timestamptz NOT NULL DEFAULT now(),
locked_at timestamptz,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
The insert is intentionally boring:
INSERT INTO inbound_jobs (event_key, smtpfast_received_id, payload)
VALUES ($1, $2, $3::jsonb)
ON CONFLICT (event_key) DO NOTHING;
In webhook mode, return 202 whether the insert creates a job or finds an existing event key. Mailroom returns 503 if it cannot enqueue the event, allowing SMTPfast to retry. Retries are finite, so failed deliveries still need monitoring and replay after the provider exhausts them.
Do not use an in-memory Set for this. It forgets everything on a restart and cannot coordinate multiple application instances.
Claim durable jobs with row locks
The queue is a PostgreSQL table, so pending work survives an app restart. A worker claims a job by marking it as processing. FOR UPDATE SKIP LOCKED lets workers claim different rows without waiting for rows another worker is currently locking:
WITH next_jobs AS (
SELECT id
FROM inbound_jobs
WHERE (status = 'pending' AND available_at <= now())
OR (status = 'processing'
AND locked_at < now() - interval '5 minutes')
ORDER BY available_at, created_at
FOR UPDATE SKIP LOCKED
LIMIT $1
)
UPDATE inbound_jobs AS jobs
SET status = 'processing',
attempts = attempts + 1,
locked_at = now()
FROM next_jobs
WHERE jobs.id = next_jobs.id
RETURNING jobs.*;
The second condition recovers jobs left behind by a crashed process. After five minutes, another worker can claim them again.
This five-minute lease is not an exactly-once guarantee. A job that outlives its lease can be reclaimed; the unique messages.smtpfast_received_id constraint is the final guard against storing the same received message twice. Row locks also do not guarantee that two different replies in one conversation finish in order. If your workflow depends on that ordering, serialize processing per conversation.
Failed jobs use exponential backoff capped at five minutes, with eight processing attempts by default. Non-retryable API responses, such as a permanent 404, go directly to failed. A production deployment should alert on those rows and expose a dead-letter review workflow.
The web server and worker run in one process for this example. They can be separated when they need independent scaling; queue age and failed-job counts help reveal when processing is falling behind.
Fetch the full message in the worker
The email.received event contains metadata, not the bodies. The worker retrieves the message by its SMTPfast ID:
const response = await fetch(
`${baseUrl}/emails/receiving/${encodeURIComponent(receivedId)}`,
{
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(10_000),
},
);
The full object includes plain text, HTML, headers, attachment metadata, the SMTP envelope, authentication and scanning verdicts, and threading fields.
An inbound domain is effectively catch-all: every local address can receive. Mailroom should create tickets only for one configured mailbox. It checks the envelope recipient rather than the visible header:
const envelopeRecipients = message.received_for.length > 0
? message.received_for
: message.envelope_to;
const intendedForMailbox = envelopeRecipients
.map((address) => address.trim().toLowerCase())
.includes(config.inboundAddress);
if (!intendedForMailbox) {
await store.markJobIgnored(
job.id,
`Envelope recipient did not match ${config.inboundAddress}`,
);
return;
}
Why not trust To? A message can be Bcc'd, forwarded, or carry a forged display header. SMTP routing happens against the envelope, and SMTPfast exposes the actual recipient through received_for and envelope_to.
Turn email headers into conversations
The first message creates a ticket. Later replies should attach to it.
Email clients express conversation relationships using three headers:
-
Message-IDuniquely identifies a message. -
In-Reply-Tonormally points at the message being answered. -
Referencescarries the earlier IDs in the conversation.
Mailroom stores the incoming Message-ID. For a new message, it searches both In-Reply-To and References:
SELECT tickets.id, tickets.number
FROM messages
JOIN tickets ON tickets.id = messages.ticket_id
WHERE messages.internet_message_id = ANY($1::text[])
ORDER BY messages.received_at DESC
LIMIT 1;
Some clients or forwarding systems strip threading headers. Replies sent by Mailroom therefore include a ticket marker in the subject:
Re: [Ticket #42] CSV export is missing last week
The subject marker is a fallback, not the primary key. Header-based matching runs first because users can type or copy subject text themselves. Mailroom accepts the fallback only when the normalized sender address matches the ticket requester; otherwise it creates a new ticket. That is a routing safeguard, not authentication-the From header is still untrusted and no email should authorize a sensitive account action by itself.
The final database transaction performs all of these operations together:
- Check whether the received SMTPfast message ID already exists.
- Resolve a ticket from threading headers or the subject marker.
- Create a ticket when neither produces a match.
- Insert the message and attachment metadata.
- Mark the queue job complete.
If the process stops before commit, none of those changes become visible and the stale job can be reclaimed safely.
Treat email content as hostile input
The easiest way to create an XSS problem is to place message.html inside the support UI.
Email HTML can contain tracking pixels, deceptive links, CSS designed to hide content, and markup that was never intended to share a document with your application. Mailroom takes the conservative route: it stores and displays the plain-text body, and every value entering an HTML template is escaped.
export function escapeHtml(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
The UI also ships a restrictive Content Security Policy and does not load remote images.
SMTPfast supplies SPF, DKIM, DMARC, spam, and virus verdicts. These are useful context, not a substitute for application authorization. A message passing SPF proves something about its sending path; it does not mean the person is allowed to update an order or access an account.
When the virus verdict does not pass, SMTPfast quarantines the message and does not expose its bodies, raw file, or attachments. Mailroom stores only the available metadata and flags the message as quarantined. A new ticket created from that message also receives the quarantined status.
An HTML-only message without a plain-text part appears without body text in this demo. Adding an HTML-to-text conversion step would improve that case without rendering the original HTML.
Sample conversation from the demo seed data.
Keep attachment access short-lived
The message object contains attachment metadata, but download URLs are minted on request and expire after 15 minutes. Mailroom stores:
SMTPfast attachment ID
filename
declared content type
size
It does not save the temporary URL. When an authenticated agent clicks an attachment, the app asks SMTPfast for a fresh URL and redirects only if the result uses HTTPS.
This keeps expired credentials out of the database, but it does not make the file safe. Filenames and MIME types are attacker-controlled. A production workflow that redistributes attachments should scan them again, enforce size and type policy based on file contents, and preferably use content-disarm tooling for risky document formats.
SMTPfast currently retains received messages and attachments for up to 30 days. If tickets must preserve files longer, copy approved attachments into storage you control and define a lifecycle policy there.
That provider retention window does not remove the message text already copied into PostgreSQL. Your application needs its own retention and deletion policy for stored conversations.
Reply without breaking the email thread
To answer a ticket, Mailroom finds its latest inbound message. If that message is quarantined, it blocks the reply; otherwise it calls the reply endpoint:
const response = await fetch(
`${baseUrl}/emails/receiving/${receivedId}/reply`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(10_000),
body: JSON.stringify({
subject: `Re: [Ticket #${ticket.number}] ${ticket.subject}`,
text: replyText,
}),
},
);
SMTPfast adds In-Reply-To and References for the received conversation. These let the recipient's email client associate the reply with the thread, although the client ultimately controls its display. The next answer carries references we can match back to the ticket.
SMTPfast chooses the sending address from the receiving domain or its verified parent. For a receive-only subdomain, it sets Reply-To back to the inbound address. It also chooses the recipient from the original message's Reply-To, falling back to From; those fields remain untrusted email input. See the reply endpoint's address rules.
After SMTPfast accepts the reply, Mailroom stores the returned outbound email ID beside the local message. That correlation ID is the bridge to delivery logs and later delivery events.
Acceptance is not proof of delivery. Also, sending and recording the reply are separate operations: a crash between them could leave a sent reply missing locally, and submitting the form again can send another copy. A production version needs an outbound outbox, reconciliation, and provider-supported idempotency where available.
The reply form is protected by Basic authentication and a ticket-bound HMAC form token. The token is not a replacement for user sessions, but it prevents an unrelated site from constructing a valid reply form submission.
For a shared support inbox, this is also where user roles, ticket ownership, state changes, and an audit trail become useful. Those features belong to the application using the email pipeline and can follow its existing authentication and authorization model.
Optional: switch to signed webhooks
You can keep polling for the entire local tutorial. To use webhooks after deploying behind public HTTPS, create a standard webhook subscribed to email.received, with this endpoint:
https://your-app.example/webhooks/smtpfast
Copy its signing secret into .env, change the ingestion mode, and restart the app:
INGEST_MODE=webhook
SMTPFAST_WEBHOOK_SECRET=whsec_replace_me
Once configured, replay a delivery from SMTPfast's webhook log and confirm it does not add a second ticket or message. The queue's status and last_error fields help distinguish ignored recipients, retries, and failed jobs.
The signing secret verifies requests arriving at your app. The API key authorizes requests your app sends to SMTPfast; keep them separate.
SMTPfast retries unsuccessful webhook deliveries after 1 second, 30 seconds, 5 minutes, and 30 minutes. Keeping the handler short helps avoid timeouts; the webhook documentation describes the complete policy.
Standard SMTPfast webhooks include X-SMTPfast-Signature, an HMAC-SHA256 digest of the raw request body.
The word raw matters. If a framework parses JSON and later serializes it again, whitespace or property order may change. The content still means the same thing, but the bytes no longer match the signed bytes.
Mailroom tells Fastify to keep JSON bodies as a Buffer:
app.removeContentTypeParser("application/json");
app.addContentTypeParser(
"application/json",
{ parseAs: "buffer" },
(_request, body, done) => done(null, body),
);
Signature verification uses buffers and a constant-time comparison:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhookSignature(
rawBody: Buffer,
signature: string | undefined,
secret: string,
): boolean {
if (!signature || !/^[a-f\d]{64}$/i.test(signature)) {
return false;
}
const expected = createHmac("sha256", secret)
.update(rawBody)
.digest();
const supplied = Buffer.from(signature, "hex");
return supplied.length === expected.length &&
timingSafeEqual(supplied, expected);
}
Only after verification do we call JSON.parse and validate the shape with Zod. This excerpt shows that ordering; the full handler also catches malformed JSON, returns 400 for invalid payloads, and enqueues the validated event before returning 202:
app.post("/webhooks/smtpfast", async (request, reply) => {
const rawBody = request.body;
if (!Buffer.isBuffer(rawBody)) {
return reply.code(415).send({ error: "Expected application/json" });
}
const signature = request.headers["x-smtpfast-signature"];
if (!verifyWebhookSignature(
rawBody,
typeof signature === "string" ? signature : undefined,
config.smtpfastWebhookSecret,
)) {
return reply.code(401).send({ error: "Invalid webhook signature" });
}
const payload = JSON.parse(rawBody.toString("utf8"));
if (payload.type !== "email.received") {
return reply.code(204).send();
}
const event = smtpfastWebhookEventSchema.parse(payload);
// Enqueue next...
});
A valid signature establishes that the bytes were signed with the shared secret and have not changed. It does not establish freshness or make the email's contents trustworthy. The sender, subject, body, filenames, MIME types, and headers remain untrusted input.
Test the failure boundaries
The automated tests do not send real email. The fast suite uses test doubles for the provider client and persistence layer; the integration suite uses a real PostgreSQL database.
The fast tests cover:
- Correct and forged webhook signatures
- Polling discovery and duplicate suppression
- The fact that changing raw JSON whitespace invalidates the signature
- Duplicate webhook acknowledgement
- Rejection of unauthenticated ticket access
- Envelope-based mailbox routing
- Ignoring other addresses on the catch-all domain
- Retryable and permanent provider failures
The PostgreSQL integration tests exercise the real schema. One creates two received messages whose References headers point to the same conversation and asserts that both land on one ticket. Another supplies a quarantined body and attachment, then asserts that neither was stored.
The third checks that a different sender cannot join a ticket merely by copying its subject marker.
Run the fast suite with:
npm test
npm run typecheck
npm run build
The three database tests are skipped unless TEST_DATABASE_URL is set. Use a dedicated test database: the integration suite truncates its tables before each test. Create it once and apply the schema before running the suite:
docker compose exec postgres createdb -U tickets tickets_test
DATABASE_URL=postgres://tickets:tickets@localhost:55432/tickets_test \
npm run db:migrate
TEST_DATABASE_URL=postgres://tickets:tickets@localhost:55432/tickets_test \
npm run test:integration
Signature tests should use the exact Buffer sent to the handler. Testing only the HMAC helper without the framework parser can miss the most common integration mistake.
For more detail on the provider API:
Conclusion
Email can be a useful interface to an application, especially when people already use it to ask for help, share documents, or continue a conversation. Mailroom shows how that familiar interaction can become structured data: an incoming message creates a ticket, later replies join it, and an agent's response travels back through email.
SMTPfast handles receiving and parsing the message. The application decides what it means, how it connects to existing records, and who can act on it. Keeping discovery, background processing, and display separate makes those decisions easier to understand and adapt. Polling lets you explore the flow locally; signed webhooks offer another way to discover messages once the app is deployed.
To use the pattern elsewhere, start with the point where Mailroom creates a ticket. That could instead create a bug report, register a document for review, or attach a reply to an existing discussion. Try a conversation in the example app, follow it through the code, and use the pieces that fit your workflow.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.
