When Lettr receives an email on your inbound domain, it automatically parses the raw email into a structured format. This page explains how different parts of the email are extracted and made available in the webhook payload.
Parsed Email Structure
Inbound emails are delivered as relay.relay_delivery webhook events. Each event contains a relay message with parsed email content:
[
{
"msys": {
"relay_message": {
"msg_from": "sender@example.com",
"rcpt_to": "support@mail.example.com",
"friendly_from": "John Doe <sender@example.com>",
"customer_id": "1234",
"webhook_id": "wh_abc123",
"content": {
"html": "<html><body><p>HTML content...</p></body></html>",
"text": "Plain text content of the email...",
"subject": "Question about my order",
"headers": [
{ "Message-ID": "<unique-id@example.com>" },
{ "Date": "Mon, 15 Jan 2024 10:30:00 +0000" },
{ "In-Reply-To": "<previous-id@example.com>" },
{ "References": "<original-id@example.com>" },
{ "Content-Type": "multipart/mixed" },
{ "To": ["support@mail.example.com"] },
{ "CC": ["team@mail.example.com"] }
],
"to": ["support@mail.example.com"],
"email_rfc822": "...raw RFC 822 MIME content..."
}
}
}
}
]
Webhook payloads are delivered as a JSON array of events. A single delivery may contain one or more events.
From Address
The relay message provides two sender fields:
msg_from — the envelope sender (SMTP MAIL FROM), always a bare email address
friendly_from — the display name and address from the From header
const relay = event.msys.relay_message;
console.log(relay.msg_from); // "sender@example.com"
console.log(relay.friendly_from); // "John Doe <sender@example.com>"
To extract just the email address or display name from friendly_from:
function parseFriendlyFrom(friendlyFrom) {
const match = friendlyFrom.match(/^(.+?)\s*<([^>]+)>$/);
if (match) {
return { name: match[1].trim(), email: match[2] };
}
return { name: null, email: friendlyFrom };
}
Reply-To Address
The Reply-To address is available in the content.headers array:
const replyToHeader = relay.content.headers.find(h => h['Reply-To']);
const replyAddress = replyToHeader?.['Reply-To'] || relay.msg_from;
Recipients
Recipients
The relay message provides:
rcpt_to — the envelope recipient (the address on your inbound domain that received the email)
content.to — the To header addresses from the email
- CC and BCC addresses are available in the
content.headers array
const relay = event.msys.relay_message;
// Envelope recipient (your inbound address)
const recipient = relay.rcpt_to; // "support@mail.example.com"
// Header recipients
const toAddresses = relay.content.to; // ["support@mail.example.com"]
const ccHeader = relay.content.headers.find(h => h['CC']);
const ccAddresses = ccHeader?.['CC'] || [];
BCC recipients are typically not visible in received emails since they’re stripped by the sending server.
Subject Line
The subject is available in the content object, decoded from any MIME encoding:
const { subject } = relay.content;
// Handles encoded subjects like:
// "=?UTF-8?B?SGVsbG8gV29ybGQ=?=" → "Hello World"
// "=?ISO-8859-1?Q?=C4pfel?=" → "Äpfel"
Handling Missing Subjects
const subject = relay.content.subject || '(No Subject)';
Email Body
Plain Text and HTML
Emails can contain plain text, HTML, or both. These are available in the content object:
const { text, html } = relay.content;
// Use HTML if available, fall back to plain text
const displayContent = html || `<pre>${escapeHtml(text)}</pre>`;
// Or prefer plain text for processing
const processableContent = text || stripHtml(html);
import { convert } from 'html-to-text';
function getPlainText(email) {
if (email.text) {
return email.text;
}
if (email.html) {
return convert(email.html, {
wordwrap: 130,
selectors: [
{ selector: 'a', options: { ignoreHref: true } },
{ selector: 'img', format: 'skip' }
]
});
}
return '';
}
Handling Quoted Content
Strip quoted replies to get just the new content:
function stripQuotedContent(text) {
const lines = text.split('\n');
const newContent = [];
for (const line of lines) {
// Stop at common quote markers
if (line.match(/^>/) ||
line.match(/^On .+ wrote:/) ||
line.match(/^-{3,}.*Original Message.*-{3,}/i) ||
line.match(/^From:.*@/)) {
break;
}
newContent.push(line);
}
return newContent.join('\n').trim();
}
const newReplyContent = stripQuotedContent(relay.content.text);
Standard Headers
Headers are available in content.headers as an array of single-key objects:
| Header | Description |
|---|
Message-ID | Unique identifier for this email |
Date | When the email was sent |
In-Reply-To | Message-ID of the email being replied to |
References | Chain of Message-IDs in the conversation |
Content-Type | MIME type of the email body |
X-Mailer | Email client used to send |
X-Priority | Email priority (1=High, 3=Normal, 5=Low) |
const { headers } = relay.content;
// Helper to find a header value
function getHeader(headers, name) {
const header = headers.find(h => h[name] !== undefined);
return header ? header[name] : null;
}
// Get the message thread
const messageId = getHeader(headers, 'Message-ID');
const inReplyTo = getHeader(headers, 'In-Reply-To');
const references = getHeader(headers, 'References')?.split(/\s+/) || [];
// Check priority
const priority = getHeader(headers, 'X-Priority');
const isHighPriority = priority === '1' || priority === '2';
Access any header using the helper:
// Custom headers set by the sender
const customHeader = getHeader(headers, 'X-Custom-Header');
const mailingList = getHeader(headers, 'List-Id');
const unsubscribeUrl = getHeader(headers, 'List-Unsubscribe');
Date and Time
Sent Timestamp
The original send time is in the Date header:
const dateHeader = getHeader(relay.content.headers, 'Date');
const sentAt = new Date(dateHeader);
Character Encoding
Lettr automatically handles character encoding conversion:
// These are all decoded to UTF-8:
// - ISO-8859-1 encoded content
// - Windows-1252 encoded content
// - Base64 encoded UTF-8
// - Quoted-Printable encoded content
const { text, subject } = relay.content;
// Both are UTF-8 strings regardless of original encoding
Multipart Emails
Emails with multiple parts (text + HTML + attachments) are automatically parsed:
// A multipart email:
// - text/plain part → relay.content.text
// - text/html part → relay.content.html
// - Full raw MIME → relay.content.email_rfc822
const hasRichContent = relay.content.html && relay.content.text;
The email_rfc822 field contains the complete raw MIME message, which you can parse with a MIME library if you need access to attachments or other parts not extracted into the structured fields.
Raw MIME Parsing
For advanced use cases like extracting inline images, embedded attachments, or handling complex MIME structures, parse the raw email_rfc822 content with a MIME library:
import { simpleParser } from 'mailparser';
async function parseRawEmail(relay) {
const parsed = await simpleParser(relay.content.email_rfc822);
return {
from: parsed.from?.value,
to: parsed.to?.value,
subject: parsed.subject,
text: parsed.text,
html: parsed.html,
attachments: parsed.attachments // includes inline images with cid
};
}
Parsing Examples
function extractSignature(text) {
// Common signature delimiters
const delimiters = [
/^--\s*$/m, // Standard delimiter
/^_{3,}$/m, // Underscores
/^-{3,}$/m, // Dashes
/^Sent from my /m, // Mobile signatures
/^Get Outlook for /m // Outlook mobile
];
let signatureStart = text.length;
for (const delimiter of delimiters) {
const match = text.match(delimiter);
if (match && match.index < signatureStart) {
signatureStart = match.index;
}
}
return {
body: text.substring(0, signatureStart).trim(),
signature: text.substring(signatureStart).trim()
};
}
Detect Email Client
function detectEmailClient(headers) {
const mailer = getHeader(headers, 'X-Mailer') || getHeader(headers, 'User-Agent') || '';
const received = getHeader(headers, 'Received') || '';
if (mailer.includes('Microsoft Outlook')) return 'Outlook';
if (mailer.includes('Apple Mail')) return 'Apple Mail';
if (mailer.includes('Thunderbird')) return 'Thunderbird';
if (received.includes('gmail.com')) return 'Gmail';
if (received.includes('outlook.com')) return 'Outlook.com';
return 'Unknown';
}
Parse Structured Data
// Extract order number from confirmation emails
function extractOrderNumber(email) {
const content = email.text || email.html;
const patterns = [
/order\s*#?\s*:?\s*([A-Z0-9-]+)/i,
/order\s+number\s*:?\s*([A-Z0-9-]+)/i,
/confirmation\s*#?\s*:?\s*([A-Z0-9-]+)/i
];
for (const pattern of patterns) {
const match = content.match(pattern);
if (match) return match[1];
}
return null;
}
Handling Edge Cases
Empty Content
function getEmailContent(email) {
const text = email.text?.trim();
const html = email.html?.trim();
if (!text && !html) {
return {
hasContent: false,
content: '',
contentType: 'none'
};
}
return {
hasContent: true,
content: text || stripHtml(html),
contentType: text ? 'text' : 'html'
};
}
function parseEmailAddress(address) {
if (!address) return null;
// Handle various formats
const match = address.match(/<([^>]+)>/) || // "Name <email>"
address.match(/([^\s<>]+@[^\s<>]+)/); // bare email
return match ? match[1].toLowerCase() : null;
}