Context
After the MVP (#599) provides web-based ticket creation and handling, this issue adds the ability for users to create and reply to tickets via email. Users email support@[FDM_APP_URL] and it becomes a ticket; when they reply to a notification email, it threads into the existing conversation.
Depends on: #599
Scope
In Scope
- Postmark inbound webhook endpoint (
/api/webhooks/inbound-email/$secret)
- Secret URL token for webhook authentication (Postmark doesn't support HMAC)
- Rate limiting on the webhook (10 req/s per sender)
- Blocked emails table + check (spam/abuse prevention)
- Body size validation (reject payloads > 30MB)
- HTML sanitization (strip scripts, iframes, dangerous tags from incoming HTML emails)
- Email-to-ticket flow: new email from unknown ticket → create new ticket (+ AI triage)
- Reply threading: extract
TK-XXXXXX from subject/In-Reply-To header → add message to existing ticket
- User matching: match sender email to existing fdm-authn user; create "email-only requester" if no account
- Notification email headers: add
In-Reply-To and References headers so reply threading works in email clients
- Integration tests with mocked Postmark payloads
Out of Scope
Acceptance Criteria
Technical Implementation
1. Webhook Route
// fdm-app/app/routes/api.webhooks.inbound-email.$secret.ts
export async function action({ request, params, context }: Route.ActionArgs) {
// 1. Validate secret token
if (params.secret !== process.env.POSTMARK_INBOUND_SECRET) {
return new Response("Unauthorized", { status: 401 })
}
// 2. Validate content-length
const contentLength = parseInt(request.headers.get("content-length") ?? "0")
if (contentLength > 30 * 1024 * 1024) {
return new Response("Payload too large", { status: 413 })
}
// 3. Parse Postmark inbound payload
const payload = await request.json()
// 4. Check blocked senders
// 5. Rate limit check
// 6. Extract ticket ref from subject or headers
// 7. Route: new ticket or reply to existing
// 8. Sanitize HTML body
// 9. Run AI triage (for new tickets)
// 10. Create ticket/message
}
2. Reply Threading Logic
Incoming email subject: "Re: [TK-A7K2M4] Perceel niet zichtbaar"
↓
Extract TK-XXXXXX from subject (regex: /\[?(TK-[A-Z0-9]{6})\]?/)
↓
Found? → Add message to existing ticket
Not found? → Check In-Reply-To header against sent Message-IDs
Not found? → Create new ticket
3. Security Measures
| Measure |
Implementation |
| Secret URL |
POSTMARK_INBOUND_SECRET env var in route path |
| Rate limiting |
In-memory counter per sender email (10/min) |
| Blocked senders |
blocked_emails table lookup before processing |
| Body sanitization |
Strip <script>, <iframe>, on* attributes, javascript: URLs |
| Size limit |
Reject payloads > 30MB at content-length check |
4. Outbound Headers (update to Issue 1 email templates)
Add to all outbound notification emails:
Message-ID: <ticket-{ticket_id}-msg-{message_id}@[FDM_APP_URL]>
References: <ticket-{ticket_id}@fdm.nl>
In-Reply-To: <ticket-{ticket_id}@fdm.nl>
Subject: [TK-A7K2M4] Perceel niet zichtbaar op kaart
5. Schema Addition
export const blockedEmails = fdmHelpdeskSchema.table("blocked_emails", {
email: text().primaryKey(),
reason: text(),
blocked_by: text().notNull(),
created: timestamp({ withTimezone: true }).notNull().defaultNow(),
})
Environment Variables
| Variable |
Description |
POSTMARK_INBOUND_SECRET |
Random token for webhook URL (generate with openssl rand -hex 32) |
Testing Requirements
- Integration tests: Mocked Postmark JSON payload → new ticket created, reply threaded, blocked sender rejected
- Unit tests: Subject parsing regex, HTML sanitization, rate limiter
- Manual test: Configure Postmark inbound domain, send real email, verify ticket appears
Definition of Done
Users can email support@[FDM_APP_URL] to create tickets, and reply to notification emails to continue the conversation — all without needing to log into the app.
Context
After the MVP (#599) provides web-based ticket creation and handling, this issue adds the ability for users to create and reply to tickets via email. Users email
support@[FDM_APP_URL]and it becomes a ticket; when they reply to a notification email, it threads into the existing conversation.Depends on: #599
Scope
In Scope
/api/webhooks/inbound-email/$secret)TK-XXXXXXfrom subject/In-Reply-To header → add message to existing ticketIn-Reply-ToandReferencesheaders so reply threading works in email clientsOut of Scope
Acceptance Criteria
support@[FDM_APP_URL]creates a ticket (if sender not blocked)TK-XXXXXXin subject line AND viaIn-Reply-ToheaderMessage-IDandReferencesheadersTechnical Implementation
1. Webhook Route
2. Reply Threading Logic
3. Security Measures
POSTMARK_INBOUND_SECRETenv var in route pathblocked_emailstable lookup before processing<script>,<iframe>,on*attributes,javascript:URLs4. Outbound Headers (update to Issue 1 email templates)
Add to all outbound notification emails:
5. Schema Addition
Environment Variables
POSTMARK_INBOUND_SECRETopenssl rand -hex 32)Testing Requirements
Definition of Done
Users can email
support@[FDM_APP_URL]to create tickets, and reply to notification emails to continue the conversation — all without needing to log into the app.