Appearance
Email-to-issue ingest
Forge ships a thin inbound webhook that turns a signed email-shaped payload into a new issue. It's the contract — your deployment owns the upstream provider wiring (Postmark, SendGrid, Mailgun, etc.). Off by default; admins enable it per-workspace and rotate a shared HMAC secret in Settings → Integrations.
Today this is a stub: there's no UI inbox, no thread stitching, no scrape-from-IMAP loop. The endpoint is the door — when you wire a provider's inbound parse webhook to it, signed payloads flow in and issues come out.
The endpoint
POST /api/ingest/emailHeaders:
| Header | Value |
|---|---|
content-type | application/json |
x-forge-email-signature | hex-encoded HMAC-SHA256 of the raw body |
Body (JSON):
json
{
"workspaceKey": "AXI",
"from": "alice@example.com",
"subject": "Bug: dashboard crashes on load",
"body": "Steps to reproduce…",
"replyTo": "alice@example.com",
"headers": { "Message-ID": "<…>" },
"attachments": [
{
"filename": "console.log",
"mimeType": "text/plain",
"base64": "PHN0YWNrIHRyYWNlPg=="
}
]
}Response (200):
json
{
"ok": true,
"issueId": "cl…",
"number": 142,
"attachmentsLinked": 1,
"attachmentsFailed": 0
}Auth flow
- Resolve workspace by
workspaceKey. Returns 404 on miss. - Reject if
Workspace.emailIngestEnabled = false→ 403. - Compute the HMAC of the raw body using the workspace's
emailIngestSecretand timing-safe-compare against thex-forge-email-signatureheader. Mismatch → 401. - Try to resolve the
fromaddress against a workspace member's email. When matched, the resulting issue'sclaimedByIdis set to that user. Otherwise the issue lands unassigned (queueable). - Create the issue in the workspace's default status with
title = subjectandbody = "From: <from>\n\n<body>". RecordsISSUE_CREATEDin audit + activity, with thesource: "email-ingest"tag in the event payload. - Stream attachments (if any) directly to MinIO using the workspace bucket. Allowlist mismatches and oversize files are skipped (logged, not failed). Each successful upload links a row through
AttachmentwithtargetType = "issue".
Generating the signature
Pseudocode:
js
import { createHmac } from "node:crypto";
const body = JSON.stringify(payload); // raw bytes you POST
const sig = createHmac("sha256", secret) // workspace's secret
.update(body)
.digest("hex");
await fetch("https://forge.example.com/api/ingest/email", {
method: "POST",
headers: {
"content-type": "application/json",
"x-forge-email-signature": sig,
},
body,
});A curl example
bash
SECRET="feis_…"
BODY='{"workspaceKey":"AXI","from":"alice@example.com","subject":"Hi","body":"Hello"}'
SIG=$(printf %s "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -sS https://forge.example.com/api/ingest/email \
-H "content-type: application/json" \
-H "x-forge-email-signature: $SIG" \
-d "$BODY"Operating notes
- Off by default. Flipping
emailIngestEnabledon without a generated secret is harmless — every signed call returns 401 (no secret = empty expected hash). - Rotate freely. The settings page exposes a "Rotate secret" button; rotating invalidates outstanding integrations until they pick up the new value. The secret is shown once after rotation.
- No replay protection. Unlike the plugin webhook contract, there's no timestamp + tolerance window — providers vary too much in what timestamps they emit. If you need stricter replay defense, put it in your provider's edge worker.
What's not covered yet
- Threading: replies don't append to existing issues. Future iter will parse
In-Reply-To/Referencesand append a comment when a matching issue is found. - HTML body parsing: the
bodyfield is treated as plain text / markdown verbatim. HTML-only senders need to convert before POST. - Provider integration UX: there's no first-class Postmark or SendGrid setup wizard. You wire the provider's inbound parse webhook to
/api/ingest/emailyourself.