Skip to content

Inbound Email-to-Ticket #600

@SvenVw

Description

@SvenVw

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

  • Postmark inbound webhook configured with secret URL token
  • New email to support@[FDM_APP_URL] creates a ticket (if sender not blocked)
  • AI triage runs on email body (same as web tickets)
  • Reply to notification email threads into the correct ticket
  • Threading works via TK-XXXXXX in subject line AND via In-Reply-To header
  • Sender email matched to existing user account when possible
  • Emails from unknown senders create a ticket with email as requester identifier
  • Blocked email addresses are rejected (no ticket created)
  • Rate limiting prevents flood from single sender
  • HTML email bodies are sanitized (no XSS vectors stored)
  • Malformed/oversized payloads return 400 without crashing
  • Outbound notification emails include proper Message-ID and References headers
  • Integration tests cover: new ticket from email, reply threading, blocked sender, rate limit

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions