If you watch more than a couple of active repositories, GitHub’s notification emails pile up fast: a new issue here, a review request there, a comment that @-mentions you three threads deep. The information you actually want to act on — which repo, which PR, who did what, and why you were pinged — is real, but it is scattered across the Subject line, a handful of X-GitHub-* headers, and the message footer. Parsing that by hand with regex is the kind of code that works on Tuesday and breaks when GitHub tweaks a template on Wednesday.
This post shows how to turn those emails into typed, validated JSON you can route to a queue, a chat bot, or a triage dashboard — using a JSON Schema and a single POST to MailFrame’s /v1/parse endpoint. If you want the ready-made schema reference, it lives at Parse GitHub Notification Emails; this post is the why-and-how around it.
What’s actually in a GitHub notification email
GitHub encodes a surprising amount of structure into each notification, but not in the body where you would expect it. The signal is spread across three places:
- The
Subjectline carries the repository, the thread title, and the thread number — e.g.Re: [octocat/Hello-World] Add retry backoff to webhook sender (#482). - The headers carry the machine-readable bits.
X-GitHub-Reasontells you why you were notified (mention,review_requested,team_mention,ci_activity, …), andList-IDrepeats theowner/repoin a stable format that survives subject-line edits. - The footer carries the canonical URL (“view it on GitHub”) and, often, the first line of the comment or event body.
So the fields worth extracting are roughly:
| Field | Why you want it |
|---|---|
repository | Route by project; partition your work queue |
thread_type | issue vs pull_request vs discussion changes how you handle it |
thread_number | Dedupe and group every notification for the same thread |
actor | Who triggered the event — filter out your own actions |
action | opened, merged, commented, review_requested, … |
reason | The single most useful field for triage: a review_requested deserves a different lane than a subscribed FYI |
url | A deep link you can drop straight into Slack |
body_preview | Enough context to decide without opening the thread |
A JSON Schema for the extraction
MailFrame is schema-first: you declare the exact shape you want before anything is parsed, and every extraction is validated against that contract before it is returned. (For the reasoning behind that design, see Why We Built MailFrame Schema-First.)
Here is a compact schema for GitHub notifications. The enum constraints are not hints — they are hard constraints, so a value outside the list fails validation instead of silently landing in your database:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "github_notification",
"type": "object",
"required": ["repository", "thread_type", "actor", "reason"],
"properties": {
"repository": { "type": "string", "pattern": "^[^/]+/[^/]+$" },
"thread_type": {
"type": "string",
"enum": ["issue", "pull_request", "discussion", "commit", "release"]
},
"thread_number": { "type": "integer", "minimum": 1 },
"thread_title": { "type": "string" },
"actor": { "type": "string" },
"action": {
"type": "string",
"enum": ["opened", "closed", "merged", "commented", "reopened", "assigned", "review_requested"]
},
"reason": {
"type": "string",
"enum": ["mention", "team_mention", "review_requested", "assign", "author", "comment", "subscribed", "ci_activity"]
},
"url": { "type": "string", "format": "uri" },
"body_preview": { "type": "string" },
"date": { "type": "string", "format": "date" }
}
}
This is the same schema MailFrame ships pre-built under the id github_notification, so you do not have to register it yourself — but copying it here is a good starting point if you want to tighten or extend it for your own routing logic.
Send the raw email to /v1/parse
MailFrame’s shipped path is direct and synchronous: you POST the raw RFC 822 MIME text of the email — exactly the bytes you received — along with a schema, and you get typed JSON back in the same HTTP response. No inbox to configure, no callback to wait on.
You can reference the pre-built schema by id (schema_id) or pass an inline JSON Schema. Here it is with curl, using the built-in github_notification schema:
curl -X POST https://api.mailframe.ai/v1/parse \
-H "Authorization: Bearer $MAILFRAME_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"schema_id": "github_notification",
"raw_mime": "From: Alex Rivera <notifications@github.com>\r\nSubject: Re: [octocat/Hello-World] Add retry backoff to webhook sender (#482)\r\nX-GitHub-Reason: mention\r\nList-ID: octocat/Hello-World <Hello-World.octocat.github.com>\r\n\r\n@jordan can you take a look at the backoff logic here?\r\n\r\n—\r\nReply to this email directly, or view it on GitHub:\r\nhttps://github.com/octocat/Hello-World/pull/482#issuecomment-1234567890"
}'
The response wraps the extracted, schema-valid data in a data object alongside the parse status and any validation errors:
{
"id": "parse_8f2a1c",
"status": "completed",
"validation_errors": [],
"data": {
"repository": "octocat/Hello-World",
"thread_type": "pull_request",
"thread_number": 482,
"thread_title": "Add retry backoff to webhook sender",
"actor": "alex-rivera",
"action": "commented",
"reason": "mention",
"url": "https://github.com/octocat/Hello-World/pull/482#issuecomment-1234567890",
"body_preview": "@jordan can you take a look at the backoff logic here?",
"date": "2026-05-21"
}
}
Validate, then route
Because the result is validated against your schema before it reaches you, your application code can stay small. Check validation_errors first, then branch on the fields you trust — reason and thread_type are usually all you need to pick a lane:
interface ParseResult {
id: string;
status: "completed" | "failed";
// Empty when the extraction satisfied your schema; populated otherwise.
// See the API docs for the per-error shape — here we only need the count.
validation_errors: unknown[];
data: GitHubNotification;
}
interface GitHubNotification {
repository: string;
thread_type: "issue" | "pull_request" | "discussion" | "commit" | "release";
thread_number?: number;
thread_title?: string;
actor: string;
action?: string;
reason: string;
url?: string;
body_preview?: string;
date?: string;
}
async function parseNotification(rawMime: string): Promise<void> {
const res = await fetch("https://api.mailframe.ai/v1/parse", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ schema_id: "github_notification", raw_mime: rawMime }),
});
const result: ParseResult = await res.json();
// Validation ran server-side against your schema — surface failures, don't guess.
if (result.status !== "completed" || result.validation_errors.length > 0) {
await flagForReview(result);
return;
}
const n = result.data;
// Route by why you were notified, not just by repo.
switch (n.reason) {
case "review_requested":
await notifyReviewQueue(n.repository, n.url ?? "");
break;
case "mention":
case "team_mention":
await pingChat(`${n.actor} mentioned you on ${n.repository}#${n.thread_number}`, n.url);
break;
case "ci_activity":
// CI noise — log it, don't page anyone.
await recordCiEvent(n);
break;
default:
await archive(n);
}
}
A few practical notes:
- Trust the types, not the strings. Because
thread_typeandreasonare enum-constrained in the schema, aswitchover them is exhaustive and safe — an unexpected value would have shown up invalidation_errorsrather than reaching this code. - Dedupe on
repository+thread_number. GitHub sends a separate email per event; grouping by thread keeps a busy PR from flooding your queue. - Keep the raw MIME intact. Don’t pre-strip headers before sending —
X-GitHub-ReasonandList-IDare the signal the extractor leans on. Send the message as you received it.
Where this fits in your pipeline
The example above POSTs from wherever you already hold the message — an IMAP poller, an inbound-email webhook, a Lambda reading from S3. That keeps ingestion in your own code and under your own retries, which is the model we generally recommend; the trade-offs are laid out in How to Choose an Email Parsing API and the broader email-to-JSON API overview.
For teams that would rather receive results asynchronously, signed webhook delivery (HMAC-SHA256, with retries and delivery history) is available in early access — see Webhooks That Don’t Break for how to verify and handle those payloads. Forwarding GitHub’s mail to a unique MailFrame inbox address, instead of POSTing it yourself, is on the roadmap; until it ships, the direct POST shown here is the supported path.
The full endpoint reference — request and response shapes, error handling, and language samples — lives in the API docs. If you want to try the GitHub schema against your own notification emails, request early access and we’ll help you wire it up.