Skip to main content
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.

Sender Information

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);

Extracting Text from 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);

Email Headers

Standard Headers

Headers are available in content.headers as an array of single-key objects:
HeaderDescription
Message-IDUnique identifier for this email
DateWhen the email was sent
In-Reply-ToMessage-ID of the email being replied to
ReferencesChain of Message-IDs in the conversation
Content-TypeMIME type of the email body
X-MailerEmail client used to send
X-PriorityEmail 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';

Custom Headers

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

Extract Email Signature

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'
  };
}

Malformed Addresses

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;
}