Skip to main content

Inbound Email Processing Guide

This comprehensive guide covers receiving and processing emails sent to your domain through EDITH, including domain verification, routing rules, webhook configuration, and threading.


Overview

Inbound email processing enables your application to:

  • Receive emails sent to your domain addresses
  • Parse email content including attachments and metadata
  • Track threaded conversations with Message-ID and Thread-ID support
  • Route intelligently using wildcard domains and strict reply modes
  • Trigger automated workflows via webhooks

Flow:

  1. Email sent to username@yourdomain.com
  2. EDITH receives via MX records
  3. Email parsed and validated against routing rules
  4. Webhook notification sent to your endpoint
  5. Your application processes the incoming email

Domain Verification

Before you can receive inbound emails, your domain must be verified. EDITH supports hierarchical account structures where parent domains can automatically verify subdomains.

For detailed information about domain verification, including:

  • Parent Account vs Sub Account domain management
  • Automatic subdomain verification inheritance
  • Webhook event routing rules
  • Step-by-step verification process

👉 See the Domain Management Guide


Prerequisites

Before setting up inbound email:

  1. ✅ Add your domain to EDITH (see Domain Management Guide)
  2. ✅ Verify domain ownership via DNS records
  3. ✅ Configure MX records to point to EDITH
  4. ✅ Set up a publicly accessible webhook endpoint

Inbound Email Acceptance Rules

EDITH uses a rule-based system to determine whether incoming emails should be accepted and processed.

Rule Priority (Highest to Lowest)

1. Wildcard Domain (Highest Priority)
2. Strict Reply Mode
3. Username Match
4. Reject (Default)

1. Wildcard Domain Mode

Condition: domain.wildcard = true

Behavior:

  • Accepts ALL emails to any username under this domain
  • 🎯 Highest priority among all rules
  • ⚡ Fastest route — no username lookup required

Example:

{
"domain": "inbound.example.com",
"wildcard": true
}

With wildcard enabled:

  • anyuser@inbound.example.com → ✅ Accepted
  • support@inbound.example.com → ✅ Accepted
  • randomstring@inbound.example.com → ✅ Accepted

Use Cases:

  • Catch-all email addresses
  • Ticketing systems with dynamic addresses
  • Email parsing services
  • Testing and development environments

2. Non-Wildcard Domain (Username-Based)

Condition: domain.wildcard = false

Behavior:

  • ✅ Only specific configured usernames are accepted
  • ❌ Emails to undefined usernames are rejected
  • 🔍 Requires exact username match

Example Configuration:

{
"domain": "support.example.com",
"wildcard": false
}

// Add specific usernames
POST /v1/inbound/username
{
"user_name": "support",
"domain": "support.example.com"
}

POST /v1/inbound/username
{
"user_name": "billing",
"domain": "support.example.com"
}

Routing:

  • support@support.example.com → ✅ Accepted (configured)
  • billing@support.example.com → ✅ Accepted (configured)
  • info@support.example.com → ❌ Rejected (not configured)
  • sales@support.example.com → ❌ Rejected (not configured)

Use Cases:

  • Controlled inbox routing
  • Department-specific addresses
  • Security-conscious environments

3. Strict Reply Mode

Condition: strictReplies = true (at username level)

Behavior:

  • ✅ Only accepts replies to emails originally sent via EDITH
  • ❌ Direct or unsolicited incoming emails are rejected
  • 🔗 Validates In-Reply-To header against EDITH's message prefix

How It Works: EDITH checks if the In-Reply-To header starts with the configured message prefix (e.g., edith_mailer_):

In-Reply-To: <edith_mailer_abc123@sparrowmailer.com>
^^^^^^^^^^^
EDITH prefix ✅ Accepted

Example Configuration:

POST /v1/inbound/username
{
"user_name": "noreply",
"domain": "example.com"
}

PUT /v1/inbound/username/update/noreply@example.com
{
"domain": "example.com",
"strict_replies": true
}

Routing:

  • Customer replies to EDITH-sent email → ✅ Accepted
  • New email directly to noreply@example.com → ❌ Rejected

Use Cases:

  • No-reply addresses that only process replies
  • Automated response systems
  • Conversation tracking
  • Preventing unsolicited inbound messages

4. Decision Flow Chart

┌─────────────────────────────────────┐
│ Incoming Email Received │
└──────────────┬──────────────────────┘


┌──────────────────────┐
│ Is Wildcard = true? │
└──────┬───────────────┘

Yes ───┤
│ ✅ ACCEPT ALL EMAILS
│ (Highest Priority)

No ─────┼────────────────────┐

┌──────────────────────┐
│ Username Configured? │
└──────┬───────────────┘

No ────┤
│ ❌ REJECT

Yes ───┼─────────────────┐

┌──────────────────────────┐
│ Is Username Active? │
└──────┬───────────────────┘

No ────┤
│ ❌ REJECT

Yes ───┼──────────────────┐

┌──────────────────────┐
│ Strict Replies = ? │
└──────┬───────────────┘

Yes ───┤


┌──────────────────────────────┐
│ Is Reply to EDITH Email? │
└──────┬───────────────────────┘

No ────┤ ❌ REJECT

Yes ───┤
│ ✅ ACCEPT

No ─────┤
│ ✅ ACCEPT



5. Rule Summary Table

SettingWildcardUsername MatchStrict RepliesResult
Scenario 1trueN/AN/A✅ Accept all
Scenario 2falseNo matchN/A❌ Reject
Scenario 3falseMatchfalse✅ Accept
Scenario 4falseMatchtrue + Is Reply✅ Accept
Scenario 5falseMatchtrue + Not Reply❌ Reject

Add Incoming Domain Webhook

Endpoint

POST /v1/inbound/relay_webhook

Purpose

Configures a webhook to receive all incoming emails for a domain. This is the domain-level configuration.

Request Body

FieldTypeRequiredDescription
domainstring✅ YesYour verified domain for receiving emails (e.g., inbound.yourcompany.com)
urlstring✅ YesWebhook URL to receive incoming email notifications. Must be HTTPS and publicly accessible.
methodstring✅ YesHTTP method: "GET", "POST", "PUT", "DELETE", "PATCH". Recommended: "POST"
headersobjectNoCustom headers to include in webhook requests (e.g., authentication tokens, API keys)

Example Request

curl -X POST https://api.edith.example.com/v1/inbound/relay_webhook \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"domain": "inbound.yourcompany.com",
"url": "https://api.yourcompany.com/webhooks/incoming-email",
"method": "POST",
"headers": {
"X-Webhook-Secret": "your-secret-key-for-verification",
"Authorization": "Bearer internal-api-token"
}
}'

Response

{
"success": true,
"message": "webhook created"
}

Update Incoming Domain

Endpoint

PUT /v1/inbound/update/{domain}

Purpose

Updates the webhook configuration or enables/disables wildcard mode for an existing incoming domain.

Path Parameters

ParameterTypeRequiredDescription
domainstring✅ YesThe domain name to update (URL encoded if necessary)

Request Body

FieldTypeRequiredDescription
urlstring✅ Yes*Updated webhook URL
methodstringNoUpdated HTTP method
headersobjectNoUpdated headers (replaces existing)
wildcardbooleanNoEnable (true) or disable (false) wildcard mode

*At least one field must be provided

Example Request - Enable Wildcard

curl -X PUT https://api.edith.example.com/v1/inbound/update/inbound.yourcompany.com \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"wildcard": true,
"url": "https://api.yourcompany.com/webhooks/v2/incoming-email"
}'

Response

{
"success": true,
"message": "Incoming domain updated successfully"
}

Get Incoming Domain Configuration

Endpoint

GET /v1/inbound/{domain}

Purpose

Retrieves the current configuration for an incoming domain.

Example Request

curl -X GET https://api.edith.example.com/v1/inbound/inbound.yourcompany.com \
-H "Authorization: Bearer YOUR_TOKEN"

Response

{
"domain": "inbound.yourcompany.com",
"url": "https://api.yourcompany.com/webhooks/incoming-email",
"method": "POST",
"headers": {
"X-Webhook-Secret": "your-secret-key"
},
"wildcard": false
}

Add Incoming Email Address

Endpoint

POST /v1/inbound/username

Purpose

Creates a specific email address for receiving emails. Required when wildcard = false.

Request Body

FieldTypeRequiredDescription
user_namestring✅ YesThe local part of the email address (before @). Must be 2-64 characters. Allowed: a-z, A-Z, 0-9, ., _, +, -
domainstring✅ YesThe domain part (must be a configured incoming domain)

Username Validation Rules

Allowed Characters: a-z, A-Z, 0-9, ., _, +, -
Length: 2-64 characters
Case: Case-insensitive (stored in lowercase)

✅ Valid Examples:

  • support
  • john.doe
  • orders+tracking
  • help_desk
  • team-alpha

❌ Invalid Examples:

  • a (too short, minimum 2 characters)
  • user@name (@ symbol not allowed)
  • user name (spaces not allowed)
  • user#123 (# symbol not allowed)

Example Request

curl -X POST https://api.edith.example.com/v1/inbound/username \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"user_name": "support",
"domain": "inbound.yourcompany.com"
}'

This creates: support@inbound.yourcompany.com

Response

{
"success": true,
"message": "Username added successfully"
}

Update Incoming Email Address

Endpoint

PUT /v1/inbound/username/update/{username}

Purpose

Updates the configuration for a specific incoming email address.

Path Parameters

ParameterTypeRequiredDescription
usernamestring✅ YesThe full email address (e.g., support@inbound.yourcompany.com)

Request Body

FieldTypeRequiredDescription
domainstring✅ YesThe domain for this email address
activebooleanNoEnable (true) or disable (false) this email address
strict_repliesbooleanNoEnable strict reply mode for this address

Example Request - Disable Address

curl -X PUT "https://api.edith.example.com/v1/inbound/username/update/support@inbound.yourcompany.com" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"domain": "inbound.yourcompany.com",
"active": false
}'

Example Request - Enable Strict Replies

curl -X PUT "https://api.edith.example.com/v1/inbound/username/update/noreply@inbound.yourcompany.com" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"domain": "inbound.yourcompany.com",
"strict_replies": true
}'

Response

{
"success": true,
"message": "Incoming email updated successfully"
}

Get Email Addresses for Domain

Endpoint

GET /v1/inbound/username/{domain}

Purpose

Lists all configured incoming email addresses for a specific domain.

Example Request

curl -X GET https://api.edith.example.com/v1/inbound/username/inbound.yourcompany.com \
-H "Authorization: Bearer YOUR_TOKEN"

Response

{
"success": true,
"emails": [
{
"username": "support",
"active": true,
"strict_replies": false
},
{
"username": "sales",
"active": true,
"strict_replies": false
},
{
"username": "noreply",
"active": true,
"strict_replies": true
}
]
}

Email Threading & Message IDs

Understanding Message-ID and Thread-ID

FieldPurposeProvider Support
Message-IDUnique identifier for a single email messageAll providers
Thread-IDGroups related messages in a conversationGmail, Outlook
In-Reply-ToReferences the Message-ID of the email being replied toAll providers
ReferencesChain of Message-IDs in the conversation threadAll providers

EDITH Message-ID Format

EDITH automatically generates Message-IDs with a specific format:

Format: <edith_mailer_{unique_id}@sparrowmailer.com>

Components:

  • Prefix: edith_mailer_ (identifies EDITH-sent emails)
  • Unique ID: UUID or custom identifier from headers
  • Domain: @sparrowmailer.com (EDITH's domain)

Example:

Message-ID: <edith_mailer_550e8400-e29b-41d4-a716-446655440000@sparrowmailer.com>

Custom Message-ID Handling

Non-Gmail Providers (SMTP, Outlook, etc.)

You can provide a custom unique identifier in the Message-ID header:

{
"headers": {
"Message-ID": "order-12345-confirmation"
}
}

EDITH will wrap it with the prefix and suffix:

Result: <edith_mailer_order-12345-confirmation@sparrowmailer.com>

Gmail API

⚠️ Important: Gmail API does NOT allow custom Message-IDs.

  • EDITH will still generate the Message-ID internally
  • Gmail assigns its own internal message ID
  • Use Thread-ID for conversation tracking with Gmail

Threading for Replies

To maintain conversation threads, include these headers when sending:

{
"headers": {
"In-Reply-To": "<original-message-id>",
"References": "<message-id-1> <message-id-2> <original-message-id>"
}
}

For Gmail specifically:

{
"headers": {
"Thread-Id": "gmail-thread-id-from-original-email",
"In-Reply-To": "<original-message-id>",
"References": "<message-id-1> <message-id-2>"
}
}

Incoming Email Webhook Payload

When an email is received, EDITH sends the parsed email to your webhook.

Complete Payload Structure

{
"event": "INCOMING_EMAIL",
"details": {
"subject": "Re: Your inquiry about pricing",
"from": "customer@example.com",
"reply-to": "customer-reply@example.com",
"to": "support@inbound.yourcompany.com",
"cc": "manager@example.com",
"bcc": "",
"date": "2024-01-15T10:40:00Z",
"recipient": "support@inbound.yourcompany.com",
"body-plain": "Thank you for the information. I would like to proceed with the Pro plan...",
"body-html": "<html><body><p>Thank you for the information...</p></body></html>",
"message-id": "<CABc123def@mail.example.com>",
"in-reply-to": "<edith_mailer_abc123@sparrowmailer.com>",
"references": "<edith_mailer_abc123@sparrowmailer.com> <CABc123def@mail.example.com>",
"thread-id": "18c5a1b2d3e4f5g6",
"content-type": "multipart/alternative",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"headers": {
"X-Custom-Header": ["value1"],
"Received": ["from mail.example.com by...", "from smtp.google.com by..."]
},
"files": [
{
"filename": "proposal.pdf",
"contentType": "application/pdf",
"content": "JVBERi0xLjQKJeLjz9MKM...",
"disposition": "attachment",
"contentId": "",
"contentLocation": "",
"size": 245760
}
],
"webhook-id": "webhook_12345",
"mailer-id": "basic_imap_01JC3BBW8S9YGX2VNKG5MD7BTA",
"mailer-service": "gmail",
"label-ids": ["INBOX"]
},
"success": true
}

Field Descriptions

FieldTypeDescription
subjectstringEmail subject line
fromstringSender email address (may include display name)
reply-tostringReply-to address if different from sender
tostringPrimary recipient(s)
ccstringCarbon copy recipient(s)
bccstringBlind carbon copy recipient(s) (rarely populated)
datestringEmail send date in RFC3339 format
recipientstringThe address that received this email (your inbound address). This is the email address configured in EDITH (e.g., support@inbound.yourcompany.com). May be empty for some providers.
body-plainstringPlain text version of the email body
body-htmlstringHTML version of the email body
message-idstringUnique identifier for this email message
in-reply-tostringMessage-ID of the email being replied to
referencesstringSpace-separated list of Message-IDs in the thread
thread-idstringThread/conversation ID (Gmail, Outlook)
content-typestringMIME content type
user-agentstringEmail client used by sender
headersobjectAll email headers as key-value pairs
filesarrayAttachments (base64 encoded)
mailer-idstringEDITH configuration ID that received this email
mailer-servicestringEmail service provider: "gmail", "outlook", "imap", etc.
label-idsarrayEmail labels/folders (e.g., ["INBOX"], ["SENT"])
errorstringError message (only present when success: false)
webhook-idstringEDITH webhook configuration ID that triggered this notification

Note: Fields may be empty strings ("") if not present in the original email. Always check for empty values before using.


Error Webhook Payload

When an error occurs during email processing, EDITH sends an error payload:

{
"event": "INCOMING_EMAIL",
"details": {
"error": "Failed to parse email: invalid MIME format",
"mailer-id": "basic_imap_01JC3BBW8S9YGX2VNKG5MD7BTA",
"webhook-id": "webhook_12345"
},
"success": false
}

Error Payload Fields:

FieldTypeDescription
errorstringError message describing what went wrong
mailer-idstringEDITH configuration ID that attempted to process the email
webhook-idstringWebhook configuration ID

Common Error Scenarios:

  • MIME parsing errors: Invalid email format, corrupted email body
  • Encoding errors: Unsupported Content-Transfer-Encoding
  • Processing errors: Failed to extract attachments or headers

Handling Error Payloads:

app.post('/webhooks/incoming-email', (req, res) => {
const { event, details, success } = req.body;

if (!success) {
console.error('Email processing failed:', details.error);
console.log('Mailer ID:', details['mailer-id']);
console.log('Webhook ID:', details['webhook-id']);

// Log error, alert operations, etc.
// Still return 200 to acknowledge receipt
return res.status(200).json({ received: true });
}

// Process successful email
processEmail(details);
res.status(200).json({ received: true });
});

File Structure Details

Each file in the files array contains the following fields:

{
"filename": "document.pdf",
"contentType": "application/pdf",
"content": "JVBERi0xLjQKJeLjz9MKM...",
"disposition": "attachment",
"contentId": "doc123@example.com",
"contentLocation": "https://example.com/doc123"
}

File Fields:

FieldTypeDescription
filenamestringOriginal filename (may be empty for inline images)
contentTypestringMIME content type (e.g., "image/png", "application/pdf")
contentstringBase64-encoded file content (always base64 in JSON payload)
dispositionstring"attachment" (downloadable) or "inline" (embedded), may be empty
contentIdstringContent-ID for inline images (used with cid: in HTML), may be empty
contentLocationstringOriginal URL/location of the content (if provided), may be empty
sizenumberFile size in bytes (optional, may not be present)

Field Availability:

  • filename: Usually present, but may be empty for inline images without explicit filename
  • disposition: Present for attachments, may be empty for inline images
  • contentId: Only present for inline images referenced in HTML body
  • contentLocation: Optional, rarely present (used for web-based content references)
  • size: Optional, may not be present for all files

Example - Complete File Processing:

function processFile(file) {
// Check if inline image
const isInline = file.contentId && (file.disposition === 'inline' || !file.disposition);

// Check if regular attachment
const isAttachment = file.disposition === 'attachment' || (!file.disposition && !file.contentId);

// Get file info
const info = {
name: file.filename || 'unnamed',
type: file.contentType,
size: file.size || estimateSize(file.content),
isInline,
isAttachment,
contentId: file.contentId || null,
contentLocation: file.contentLocation || null
};

if (isInline) {
// Process as inline image
return processInlineImage(info, file.content);
} else if (isAttachment) {
// Process as attachment
return processAttachment(info, file.content);
}

// Other parts (signatures, calendar events, etc.)
return processSpecialPart(info, file.content);
}

Empty and Null Field Handling

Many fields in the webhook payload may be empty strings or missing. Always validate before use:

Common Empty Fields:

  • subject: May be empty ("") for emails without subject
  • body-html: Empty if email is plain text only
  • body-plain: Empty if email is HTML only (rare, but possible)
  • cc, bcc: Empty strings if not present
  • reply-to: Empty if same as from or not specified
  • in-reply-to: Empty if not a reply
  • references: Empty if not part of a thread
  • thread-id: Empty for non-Gmail/Outlook providers (IMAP, SparkPost)
  • user-agent: May be empty if not provided by sender
  • recipient: May be empty for some email providers (check to field as fallback)
  • content-type: May be empty, defaults to text/plain if not specified

Safe Field Access:

function safeGetField(email, field, defaultValue = '') {
const value = email[field];
return (value && value.trim()) ? value : defaultValue;
}

// Usage
const subject = safeGetField(email, 'subject', '(No Subject)');
const htmlBody = safeGetField(email, 'body-html');
const plainBody = safeGetField(email, 'body-plain', '(No content)');

// Check if reply
const isReply = !!(email['in-reply-to'] && email['in-reply-to'].trim());

// Check if has attachments
const hasAttachments = email.files && email.files.length > 0;

Understanding Email Body and Attachment Processing

Body Text Handling

Incoming emails can contain both HTML and plain text versions of the body content. EDITH extracts and provides both formats:

Plain Text Body (body-plain)

The body-plain field contains the plain text version of the email body. This is:

  • Always present (may be empty if email is HTML-only)
  • Extracted from text/plain MIME parts
  • Decoded according to Content-Transfer-Encoding (see below)
  • Use case: Fallback display, text search, accessibility

Example:

{
"body-plain": "Thank you for your inquiry. We'll get back to you soon.\n\nBest regards,\nSupport Team"
}

HTML Body (body-html)

The body-html field contains the HTML version of the email body. This is:

  • May be empty if email is plain text only
  • Extracted from text/html MIME parts
  • Decoded according to Content-Transfer-Encoding
  • May contain inline image references (cid: URLs)
  • Use case: Rich formatting display (preferred when available)

Example:

{
"body-html": "<html><body><p>Thank you for your inquiry. We'll get back to you soon.</p><p>Best regards,<br>Support Team</p></body></html>"
}

Rendering Priority

When displaying emails, follow this priority:

  1. If body-html exists and is not empty: Use HTML body (sanitize first!)
  2. Else if body-plain exists: Use plain text body
  3. Else: Display "(No content)"

Code Example:

function getEmailBody(email) {
if (email['body-html'] && email['body-html'].trim()) {
return {
type: 'html',
content: sanitizeHtml(email['body-html'])
};
} else if (email['body-plain'] && email['body-plain'].trim()) {
return {
type: 'plain',
content: email['body-plain']
};
}
return {
type: 'empty',
content: '(No content)'
};
}

Content-Transfer-Encoding Header

The Content-Transfer-Encoding header specifies how the email body and attachments are encoded. EDITH automatically decodes content based on this header before including it in the webhook payload.

Common Encoding Types

EncodingDescriptionHow EDITH Handles It
7bitASCII text (no encoding)Content provided as-is
8bit8-bit charactersContent provided as-is
quoted-printablePrintable ASCII with escape sequencesAutomatically decoded to UTF-8
base64Base64 encodingAutomatically decoded to binary/text
binaryRaw binary dataProvided as-is (rare)

How to Check Encoding

The Content-Transfer-Encoding header is available in the headers object:

{
"headers": {
"Content-Transfer-Encoding": ["base64"],
"Content-Type": ["text/html; charset=UTF-8"]
}
}

Important Notes

  1. EDITH decodes automatically: All content in body-plain, body-html, and files[].content is already decoded. You don't need to decode it again.

  2. Base64 content in files: The files[].content field contains base64-encoded data (for binary attachments), but this is different from Content-Transfer-Encoding. This is the standard way to transmit binary data in JSON.

  3. Character encoding: Check Content-Type header for charset (e.g., charset=UTF-8, charset=ISO-8859-1). EDITH converts to UTF-8.

Example - Checking Headers:

function getContentEncoding(email) {
const encoding = email.headers['Content-Transfer-Encoding']?.[0];
const contentType = email.headers['Content-Type']?.[0];

console.log('Transfer Encoding:', encoding); // e.g., "base64"
console.log('Content Type:', contentType); // e.g., "text/html; charset=UTF-8"

// Extract charset
const charsetMatch = contentType?.match(/charset=([^;]+)/i);
const charset = charsetMatch ? charsetMatch[1] : 'UTF-8';
console.log('Charset:', charset);

return { encoding, contentType, charset };
}

Example - Manual Decoding (if needed):

// Note: EDITH already decodes, but here's how you'd do it manually
function decodeContent(content, encoding) {
switch (encoding?.toLowerCase()) {
case 'base64':
return Buffer.from(content, 'base64').toString('utf-8');
case 'quoted-printable':
return decodeQuotedPrintable(content);
case '7bit':
case '8bit':
default:
return content; // Already decoded
}
}

Attachment Types and Categorization

EDITH categorizes email attachments into three types based on their Content-Disposition header and usage:

1. Regular Attachments (disposition: "attachment")

These are files meant to be downloaded, not displayed inline.

Characteristics:

  • disposition field is "attachment" or missing
  • contentId is usually empty
  • Displayed in attachments section
  • User must download to view

Example:

{
"files": [
{
"filename": "invoice.pdf",
"contentType": "application/pdf",
"content": "JVBERi0xLjQKJeLjz9MK...",
"disposition": "attachment",
"size": 245760
}
]
}

2. Inline Images (disposition: "inline" or missing with contentId)

These are images embedded in the HTML body using cid: references.

Characteristics:

  • disposition is "inline" or missing
  • contentId is present (e.g., "logo@example.com")
  • Referenced in HTML as <img src="cid:logo@example.com">
  • Should be displayed inline, not as attachment

Example:

{
"files": [
{
"filename": "logo.png",
"contentType": "image/png",
"content": "iVBORw0KGgoAAAANSUhEUgAA...",
"contentId": "logo@example.com",
"disposition": "inline"
}
]
}

How to process inline images:

function processInlineImages(htmlBody, files) {
if (!files) return htmlBody;

files.forEach(file => {
if (file.contentId && (file.disposition === 'inline' || !file.disposition)) {
const cid = file.contentId.replace(/[<>]/g, '');
const dataUri = `data:${file.contentType};base64,${file.content}`;
// Replace cid: references with data URIs
htmlBody = htmlBody.replace(
new RegExp(`cid:${cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'gi'),
dataUri
);
}
});

return htmlBody;
}

3. Other Parts (OtherParts)

MIME parts that don't fit into attachments or inlines (e.g., alternative text versions, signatures).

Common types:

  • Alternative plain text versions
  • Digital signatures (application/pkcs7-signature, application/pgp-signature)
  • Calendar events (text/calendar)
  • Contact cards (text/vcard)

Attachment Content Types

Attachments are categorized by their contentType field. Here's how to handle different types:

Documents

Content TypeDescriptionRendering
application/pdfPDF documentsShow preview or download button
application/mswordWord documents (.doc)Download only (preview requires conversion)
application/vnd.openxmlformats-officedocument.wordprocessingml.documentWord documents (.docx)Download only
application/vnd.ms-excelExcel spreadsheets (.xls)Download only
application/vnd.openxmlformats-officedocument.spreadsheetml.sheetExcel spreadsheets (.xlsx)Download only
application/vnd.ms-powerpointPowerPoint (.ppt)Download only
application/vnd.openxmlformats-officedocument.presentationml.presentationPowerPoint (.pptx)Download only

Example handling:

function isDocument(contentType) {
return contentType.startsWith('application/pdf') ||
contentType.includes('msword') ||
contentType.includes('spreadsheet') ||
contentType.includes('presentation');
}

function renderDocument(file) {
if (file.contentType === 'application/pdf') {
// Can show PDF preview
return `<iframe src="data:application/pdf;base64,${file.content}" width="100%" height="600px"></iframe>`;
}
// Other documents: download only
return `<a href="data:${file.contentType};base64,${file.content}" download="${file.filename}">Download ${file.filename}</a>`;
}

Images

Content TypeDescriptionRendering
image/jpeg, image/jpgJPEG imagesDisplay inline
image/pngPNG imagesDisplay inline
image/gifGIF imagesDisplay inline (animated GIFs supported)
image/webpWebP imagesDisplay inline
image/svg+xmlSVG vector graphicsDisplay inline (sanitize first!)
image/bmpBitmap imagesDisplay inline
image/tiffTIFF imagesDownload only (browser support limited)

Example handling:

function isImage(contentType) {
return contentType.startsWith('image/');
}

function renderImage(file, isInline = false) {
const dataUri = `data:${file.contentType};base64,${file.content}`;

if (isInline) {
return `<img src="${dataUri}" alt="${file.filename}" style="max-width: 100%; height: auto;" />`;
} else {
return `
<div class="attachment-image">
<img src="${dataUri}" alt="${file.filename}" style="max-width: 200px; height: auto;" />
<a href="${dataUri}" download="${file.filename}">Download</a>
</div>
`;
}
}

Archives

Content TypeDescriptionRendering
application/zipZIP archivesDownload only
application/x-tarTAR archivesDownload only
application/gzipGZIP compressed filesDownload only
application/vnd.rarRAR archivesDownload only
application/x-7z-compressed7-Zip archivesDownload only

Security note: Always scan archives before extraction in production!

Audio/Video

Content TypeDescriptionRendering
audio/mpeg, audio/mp3MP3 audioShow audio player
audio/wavWAV audioShow audio player
audio/oggOGG audioShow audio player
video/mp4MP4 videoShow video player
video/x-msvideoAVI videoShow video player
video/quicktimeMOV videoShow video player
video/webmWebM videoShow video player

Example handling:

function renderMedia(file) {
const dataUri = `data:${file.contentType};base64,${file.content}`;

if (file.contentType.startsWith('audio/')) {
return `<audio controls src="${dataUri}">Your browser does not support audio.</audio>`;
} else if (file.contentType.startsWith('video/')) {
return `<video controls width="100%" src="${dataUri}">Your browser does not support video.</video>`;
}

return null;
}

Special Types

Content TypeDescriptionRendering
text/calendariCalendar events (.ics)Parse and show event details
text/vcard, text/x-vcardContact cards (.vcf)Show "Add to Contacts" button
application/jsonJSON dataShow formatted JSON viewer
text/csvCSV dataShow table view or download
text/xml, application/xmlXML dataShow formatted XML viewer
message/rfc822Forwarded emailParse and display nested email

Example - Calendar event:

function parseCalendarEvent(file) {
const content = Buffer.from(file.content, 'base64').toString('utf-8');
const lines = content.split('\n');

const event = {};
let currentKey = null;

lines.forEach(line => {
if (line.startsWith('SUMMARY:')) {
event.summary = line.substring(8);
} else if (line.startsWith('DTSTART:')) {
event.start = line.substring(8);
} else if (line.startsWith('DTEND:')) {
event.end = line.substring(6);
} else if (line.startsWith('LOCATION:')) {
event.location = line.substring(9);
}
});

return event;
}

Complete Attachment Processing Example

Here's a comprehensive function to categorize and render attachments:

function categorizeAndRenderAttachments(files, htmlBody) {
const attachments = [];
const inlineImages = [];
const specialParts = [];

files.forEach(file => {
const contentType = file.contentType.toLowerCase();

// Check if inline image
if (file.contentId && (file.disposition === 'inline' || !file.disposition)) {
inlineImages.push(file);
// Process inline image in HTML body
if (htmlBody) {
const cid = file.contentId.replace(/[<>]/g, '');
const dataUri = `data:${file.contentType};base64,${file.content}`;
htmlBody = htmlBody.replace(
new RegExp(`cid:${cid}`, 'gi'),
dataUri
);
}
}
// Check if special type
else if (contentType === 'text/calendar' ||
contentType === 'text/vcard' ||
contentType === 'application/pkcs7-signature' ||
contentType === 'application/pgp-signature') {
specialParts.push(file);
}
// Regular attachment
else {
attachments.push(file);
}
});

return {
attachments, // Regular downloadable files
inlineImages, // Images embedded in HTML
specialParts, // Calendar, contacts, signatures
processedHtml: htmlBody // HTML with inline images processed
};
}

// Usage
const { attachments, inlineImages, specialParts, processedHtml } =
categorizeAndRenderAttachments(email.files || [], email['body-html']);

// Render attachments section
attachments.forEach(file => {
if (isImage(file.contentType)) {
renderImagePreview(file);
} else if (isDocument(file.contentType)) {
renderDocumentDownload(file);
} else {
renderGenericAttachment(file);
}
});

// Process special parts
specialParts.forEach(file => {
if (file.contentType === 'text/calendar') {
const event = parseCalendarEvent(file);
renderCalendarEvent(event);
} else if (file.contentType === 'text/vcard') {
renderContactCard(file);
}
});

File Size and Content Handling

File Size

The size field (if present) indicates the decoded file size in bytes:

{
"filename": "large-document.pdf",
"contentType": "application/pdf",
"content": "JVBERi0xLjQK...",
"size": 5242880 // 5 MB
}

Note: The content field is base64-encoded, so its string length will be approximately 33% larger than the actual file size.

Calculate actual size:

function getFileSize(file) {
if (file.size) {
return file.size; // Use provided size if available
}
// Estimate from base64 content
return Math.floor(file.content.length * 0.75);
}

function formatFileSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}

Content Decoding

All content in the webhook payload is already decoded by EDITH. The content field in files is base64-encoded for JSON transmission, but this is different from Content-Transfer-Encoding.

To use file content:

// For binary files (images, PDFs, etc.)
function downloadFile(file) {
const binaryString = atob(file.content); // Decode base64
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: file.contentType });

const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

// For text files (CSV, JSON, XML, etc.)
function getTextContent(file) {
return Buffer.from(file.content, 'base64').toString('utf-8');
// Or in browser:
// return atob(file.content);
}

Provider-Specific Differences

Different email providers handle certain fields differently:

Gmail (mailer-service: "gmail")

  • thread-id: Always present (Gmail's conversation ID)
  • label-ids: May include ["INBOX"], ["SENT"], ["SPAM"], ["TRASH"], or custom labels
  • recipient: Usually populated from the To field
  • Message-ID: Gmail generates its own, EDITH preserves it

Outlook (mailer-service: "outlook")

  • thread-id: Present for conversations (Outlook conversation ID)
  • label-ids: May include ["INBOX"], ["SENT"], ["DRAFTS"], etc.
  • recipient: Usually populated from the To field
  • Headers: Full Microsoft Graph headers preserved

IMAP (mailer-service: "imap")

  • thread-id: Usually empty (IMAP doesn't provide thread IDs)
  • label-ids: Folder names (e.g., ["INBOX"], ["Sent"], ["Spam"])
  • recipient: May be empty, check to field
  • Headers: All IMAP headers preserved as-is

SparkPost (mailer-service: "sparkpost")

  • thread-id: Empty (not provided by SparkPost)
  • label-ids: Usually ["INBOX"] or empty
  • recipient: Populated from RcptTo in SparkPost relay message
  • Headers: SparkPost-specific headers may be present

Handling Provider Differences:

function getThreadIdentifier(email) {
// Prefer thread-id if available (Gmail, Outlook)
if (email['thread-id'] && email['thread-id'].trim()) {
return email['thread-id'];
}

// Fallback to message-id for IMAP/SparkPost
if (email['message-id']) {
return email['message-id'];
}

// Last resort: use references
if (email.references && email.references.trim()) {
const refs = email.references.split(' ');
return refs[refs.length - 1]; // Last reference
}

return null;
}

function getRecipientAddress(email) {
// Try recipient field first
if (email.recipient && email.recipient.trim()) {
return email.recipient;
}

// Fallback to 'to' field
if (email.to && email.to.trim()) {
// Extract first email address from 'to' field
const match = email.to.match(/[\w\.-]+@[\w\.-]+\.\w+/);
return match ? match[0] : email.to;
}

return null;
}

Important Notes and Edge Cases

1. Email Filtering

EDITH automatically filters out emails sent by EDITH itself (identified by X-Is-Edith header) when processing emails from the SENT folder. These emails are not sent to your webhook.

2. Multipart Emails

Emails with multipart/alternative content type contain both HTML and plain text versions. EDITH extracts both:

  • body-html: HTML version
  • body-plain: Plain text version

Always prefer HTML when available, but provide plain text fallback.

3. Nested Emails

Emails forwarded as attachments (message/rfc822) are included in the files array. You may need to parse these separately if you want to display forwarded content.

4. Large Attachments

Very large attachments may cause webhook payloads to exceed size limits. Consider:

  • Streaming large files to storage
  • Processing attachments asynchronously
  • Setting up webhook payload size limits

5. Character Encoding

All text content is converted to UTF-8 by EDITH. The original charset is preserved in the Content-Type header if you need it:

function getCharset(email) {
const contentType = email.headers['Content-Type']?.[0] || email['content-type'] || '';
const match = contentType.match(/charset=([^;]+)/i);
return match ? match[1].trim().replace(/["']/g, '') : 'UTF-8';
}

6. Date Parsing

The date field is in RFC3339 format, but the original email may have various date formats. EDITH normalizes to RFC3339:

// Safe date parsing
function parseEmailDate(dateStr) {
try {
return new Date(dateStr); // RFC3339 format
} catch (e) {
console.warn('Invalid date format:', dateStr);
return new Date(); // Fallback to current date
}
}

7. Multiple Recipients

The to, cc, and bcc fields may contain multiple email addresses separated by commas:

function parseEmailAddresses(addressStr) {
if (!addressStr || !addressStr.trim()) return [];

return addressStr
.split(',')
.map(addr => addr.trim())
.filter(addr => addr.length > 0);
}

// Usage
const toAddresses = parseEmailAddresses(email.to);
const ccAddresses = parseEmailAddresses(email.cc);

8. Webhook Retries

If your webhook endpoint returns a non-2xx status code or times out, EDITH will retry the webhook. Ensure your endpoint:

  • Returns 200 OK quickly (within 5 seconds)
  • Processes email asynchronously if needed
  • Handles duplicate webhooks idempotently

Idempotency Example:

const processedMessageIds = new Set();

app.post('/webhooks/incoming-email', async (req, res) => {
const { details } = req.body;
const messageId = details['message-id'];

// Check if already processed
if (processedMessageIds.has(messageId)) {
console.log('Duplicate webhook, skipping:', messageId);
return res.status(200).json({ received: true, duplicate: true });
}

// Mark as processed
processedMessageIds.add(messageId);

// Respond immediately
res.status(200).json({ received: true });

// Process asynchronously
processEmailAsync(details);
});

Rendering Incoming Emails Like an Email Client

When receiving incoming email webhooks, you'll want to display them in your application similar to how Gmail, Outlook, or other email clients render emails. This section provides code examples and best practices for rendering the webhook payload data.

Key Rendering Considerations

  1. HTML vs Plain Text: Prefer HTML body when available, fallback to plain text
  2. Security: Always sanitize HTML content to prevent XSS attacks
  3. Attachments: Display attachments with download links and previews when possible
  4. Threading: Group emails by thread-id or references for conversation view
  5. Inline Images: Handle cid: references for embedded images
  6. Date Formatting: Parse and format dates in user's timezone
  7. Email Parsing: Extract display names from email addresses (e.g., "John Doe <john@example.com>")

React/TypeScript Example

A complete React component for rendering incoming emails:

import React, { useState } from 'react';
import DOMPurify from 'dompurify';
import { format, parseISO } from 'date-fns';

interface EmailFile {
filename: string;
contentType: string;
content: string;
contentId?: string;
disposition?: string;
size?: number;
}

interface IncomingEmailDetails {
subject: string;
from: string;
'reply-to'?: string;
to: string;
cc?: string;
bcc?: string;
date: string;
recipient: string;
'body-plain': string;
'body-html'?: string;
'message-id': string;
'in-reply-to'?: string;
references?: string;
'thread-id'?: string;
'content-type': string;
'user-agent'?: string;
headers: Record<string, string[]>;
files?: EmailFile[];
'mailer-id': string;
'mailer-service': string;
'label-ids': string[];
}

interface EmailViewerProps {
email: IncomingEmailDetails;
}

export const EmailViewer: React.FC<EmailViewerProps> = ({ email }) => {
const [showHeaders, setShowHeaders] = useState(false);
const [showRawHtml, setShowRawHtml] = useState(false);

// Parse email address to extract display name
const parseEmailAddress = (emailStr: string): { name?: string; address: string } => {
const match = emailStr.match(/^(.+?)\s*<(.+?)>$|^(.+)$/);
if (match) {
return match[3]
? { address: match[3] }
: { name: match[1].trim(), address: match[2].trim() };
}
return { address: emailStr };
};

// Format date
const formatDate = (dateStr: string): string => {
try {
return format(parseISO(dateStr), 'PPpp');
} catch {
return dateStr;
}
};

// Sanitize HTML content
const sanitizeHtml = (html: string): string => {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li', 'img', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'tr', 'td', 'th', 'tbody', 'thead'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'style', 'class'],
ALLOW_DATA_ATTR: false,
});
};

// Process inline images (cid: references)
const processInlineImages = (html: string, files: EmailFile[]): string => {
if (!files || files.length === 0) return html;

let processedHtml = html;
files.forEach(file => {
if (file.contentId && (file.disposition === 'inline' || !file.disposition)) {
const cid = file.contentId.replace(/[<>]/g, '');
const dataUri = `data:${file.contentType};base64,${file.content}`;
// Replace cid: references with data URIs
processedHtml = processedHtml.replace(
new RegExp(`cid:${cid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'gi'),
dataUri
);
}
});
return processedHtml;
};

// Get attachment files (non-inline)
const getAttachments = (): EmailFile[] => {
if (!email.files) return [];
return email.files.filter(
file => file.disposition === 'attachment' || (!file.disposition && !file.contentId)
);
};

// Download attachment handler
const downloadAttachment = (file: EmailFile) => {
const blob = new Blob(
[Uint8Array.from(atob(file.content), c => c.charCodeAt(0))],
{ type: file.contentType }
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.filename || 'attachment';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};

// Format file size
const formatFileSize = (bytes?: number): string => {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};

const fromParsed = parseEmailAddress(email.from);
const toParsed = parseEmailAddress(email.to);
const attachments = getAttachments();
const bodyHtml = email['body-html']
? processInlineImages(email['body-html'], email.files || [])
: null;

return (
<div className="email-viewer" style={{ fontFamily: 'system-ui, sans-serif', maxWidth: '800px', margin: '0 auto' }}>
{/* Email Header */}
<div className="email-header" style={{ borderBottom: '1px solid #e0e0e0', paddingBottom: '16px', marginBottom: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px' }}>
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 500 }}>
{email.subject || '(No Subject)'}
</h2>
{email['thread-id'] && (
<span style={{ fontSize: '12px', color: '#666', background: '#f0f0f0', padding: '4px 8px', borderRadius: '4px' }}>
Thread: {email['thread-id']}
</span>
)}
</div>

<div style={{ fontSize: '14px', color: '#333', lineHeight: '1.6' }}>
<div style={{ marginBottom: '4px' }}>
<strong>From:</strong>{' '}
{fromParsed.name ? (
<>
<span>{fromParsed.name}</span>{' '}
<span style={{ color: '#666' }}>&lt;{fromParsed.address}&gt;</span>
</>
) : (
<span>{fromParsed.address}</span>
)}
</div>

<div style={{ marginBottom: '4px' }}>
<strong>To:</strong> {toParsed.name ? `${toParsed.name} <${toParsed.address}>` : toParsed.address}
</div>

{email.cc && (
<div style={{ marginBottom: '4px' }}>
<strong>CC:</strong> {email.cc}
</div>
)}

{email['reply-to'] && email['reply-to'] !== email.from && (
<div style={{ marginBottom: '4px' }}>
<strong>Reply-To:</strong> {email['reply-to']}
</div>
)}

<div style={{ marginBottom: '4px', color: '#666' }}>
<strong>Date:</strong> {formatDate(email.date)}
</div>

{email['in-reply-to'] && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '8px', padding: '8px', background: '#f9f9f9', borderRadius: '4px' }}>
<strong>In Reply To:</strong> {email['in-reply-to']}
</div>
)}
</div>
</div>

{/* Attachments */}
{attachments.length > 0 && (
<div className="attachments" style={{ marginBottom: '16px', padding: '12px', background: '#f9f9f9', borderRadius: '4px' }}>
<strong style={{ display: 'block', marginBottom: '8px' }}>Attachments ({attachments.length}):</strong>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{attachments.map((file, idx) => (
<button
key={idx}
onClick={() => downloadAttachment(file)}
style={{
padding: '8px 12px',
border: '1px solid #ddd',
borderRadius: '4px',
background: 'white',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
<span>📎</span>
<span>{file.filename || 'unnamed'}</span>
{file.size && <span style={{ color: '#666', fontSize: '12px' }}>({formatFileSize(file.size)})</span>}
</button>
))}
</div>
</div>
)}

{/* Email Body */}
<div className="email-body" style={{ marginBottom: '16px' }}>
{bodyHtml ? (
<div
dangerouslySetInnerHTML={{ __html: sanitizeHtml(bodyHtml) }}
style={{
padding: '16px',
background: 'white',
border: '1px solid #e0e0e0',
borderRadius: '4px',
lineHeight: '1.6',
fontSize: '14px'
}}
/>
) : (
<div
style={{
padding: '16px',
background: 'white',
border: '1px solid #e0e0e0',
borderRadius: '4px',
whiteSpace: 'pre-wrap',
lineHeight: '1.6',
fontSize: '14px',
fontFamily: 'monospace'
}}
>
{email['body-plain'] || '(No content)'}
</div>
)}
</div>

{/* Email Metadata Toggle */}
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid #e0e0e0' }}>
<button
onClick={() => setShowHeaders(!showHeaders)}
style={{
padding: '8px 16px',
border: '1px solid #ddd',
borderRadius: '4px',
background: 'white',
cursor: 'pointer',
fontSize: '14px',
marginBottom: showHeaders ? '12px' : 0
}}
>
{showHeaders ? '▼' : '▶'} {showHeaders ? 'Hide' : 'Show'} Headers
</button>

{showHeaders && (
<div style={{ marginTop: '12px', padding: '12px', background: '#f9f9f9', borderRadius: '4px', fontSize: '12px', fontFamily: 'monospace' }}>
<div><strong>Message-ID:</strong> {email['message-id']}</div>
{email['thread-id'] && <div><strong>Thread-ID:</strong> {email['thread-id']}</div>}
{email.references && <div><strong>References:</strong> {email.references}</div>}
{email['user-agent'] && <div><strong>User-Agent:</strong> {email['user-agent']}</div>}
<div><strong>Content-Type:</strong> {email['content-type']}</div>
<div><strong>Mailer Service:</strong> {email['mailer-service']}</div>
<div><strong>Mailer ID:</strong> {email['mailer-id']}</div>
<div><strong>Label IDs:</strong> {email['label-ids'].join(', ')}</div>

{Object.keys(email.headers).length > 0 && (
<div style={{ marginTop: '12px' }}>
<strong>All Headers:</strong>
<pre style={{ marginTop: '8px', overflow: 'auto', maxHeight: '200px' }}>
{JSON.stringify(email.headers, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
</div>
);
};

// Usage example
export const EmailList: React.FC<{ emails: IncomingEmailDetails[] }> = ({ emails }) => {
// Group emails by thread-id for conversation view
const groupedByThread = emails.reduce((acc, email) => {
const threadId = email['thread-id'] || email['message-id'];
if (!acc[threadId]) {
acc[threadId] = [];
}
acc[threadId].push(email);
return acc;
}, {} as Record<string, IncomingEmailDetails[]>);

return (
<div>
{Object.entries(groupedByThread).map(([threadId, threadEmails]) => (
<div key={threadId} style={{ marginBottom: '24px', border: '1px solid #e0e0e0', borderRadius: '8px', padding: '16px' }}>
<h3 style={{ marginTop: 0 }}>Conversation ({threadEmails.length} messages)</h3>
{threadEmails
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map((email, idx) => (
<div key={email['message-id']} style={{ marginBottom: idx < threadEmails.length - 1 ? '16px' : 0 }}>
<EmailViewer email={email} />
</div>
))}
</div>
))}
</div>
);
};

Install required dependencies:

npm install dompurify date-fns
npm install --save-dev @types/dompurify

Vue.js Example

<template>
<div class="email-viewer">
<!-- Email Header -->
<div class="email-header">
<h2>{{ email.subject || '(No Subject)' }}</h2>
<div class="email-meta">
<div><strong>From:</strong> {{ formatEmailAddress(email.from) }}</div>
<div><strong>To:</strong> {{ formatEmailAddress(email.to) }}</div>
<div v-if="email.cc"><strong>CC:</strong> {{ email.cc }}</div>
<div><strong>Date:</strong> {{ formatDate(email.date) }}</div>
</div>
</div>

<!-- Attachments -->
<div v-if="attachments.length > 0" class="attachments">
<strong>Attachments ({{ attachments.length }}):</strong>
<div class="attachment-list">
<button
v-for="(file, idx) in attachments"
:key="idx"
@click="downloadAttachment(file)"
class="attachment-btn"
>
📎 {{ file.filename || 'unnamed' }}
<span v-if="file.size">({{ formatFileSize(file.size) }})</span>
</button>
</div>
</div>

<!-- Email Body -->
<div class="email-body">
<div
v-if="email['body-html']"
v-html="sanitizedHtml"
class="email-content html-content"
/>
<div v-else class="email-content plain-content">
{{ email['body-plain'] || '(No content)' }}
</div>
</div>

<!-- Headers Toggle -->
<div class="email-metadata">
<button @click="showHeaders = !showHeaders">
{{ showHeaders ? '▼ Hide' : '▶ Show' }} Headers
</button>
<div v-if="showHeaders" class="headers-content">
<div><strong>Message-ID:</strong> {{ email['message-id'] }}</div>
<div v-if="email['thread-id']"><strong>Thread-ID:</strong> {{ email['thread-id'] }}</div>
<div><strong>Content-Type:</strong> {{ email['content-type'] }}</div>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';
import DOMPurify from 'dompurify';
import { format, parseISO } from 'date-fns';

interface EmailFile {
filename: string;
contentType: string;
content: string;
contentId?: string;
disposition?: string;
size?: number;
}

interface EmailDetails {
subject: string;
from: string;
to: string;
cc?: string;
date: string;
'body-plain': string;
'body-html'?: string;
'message-id': string;
'thread-id'?: string;
'content-type': string;
files?: EmailFile[];
[key: string]: any;
}

const props = defineProps<{
email: EmailDetails;
}>();

const showHeaders = ref(false);

const attachments = computed(() => {
if (!props.email.files) return [];
return props.email.files.filter(
file => file.disposition === 'attachment' || (!file.disposition && !file.contentId)
);
});

const sanitizedHtml = computed(() => {
if (!props.email['body-html']) return '';

let html = props.email['body-html'];

// Process inline images
if (props.email.files) {
props.email.files.forEach(file => {
if (file.contentId && (file.disposition === 'inline' || !file.disposition)) {
const cid = file.contentId.replace(/[<>]/g, '');
const dataUri = `data:${file.contentType};base64,${file.content}`;
html = html.replace(new RegExp(`cid:${cid}`, 'gi'), dataUri);
}
});
}

return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li', 'img', 'div', 'span'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title'],
});
});

const formatEmailAddress = (emailStr: string): string => {
const match = emailStr.match(/^(.+?)\s*<(.+?)>$|^(.+)$/);
if (match) {
return match[3] || `${match[1]} <${match[2]}>`;
}
return emailStr;
};

const formatDate = (dateStr: string): string => {
try {
return format(parseISO(dateStr), 'PPpp');
} catch {
return dateStr;
}
};

const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};

const downloadAttachment = (file: EmailFile) => {
const blob = new Blob(
[Uint8Array.from(atob(file.content), c => c.charCodeAt(0))],
{ type: file.contentType }
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.filename || 'attachment';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
</script>

<style scoped>
.email-viewer {
font-family: system-ui, sans-serif;
max-width: 800px;
margin: 0 auto;
}

.email-header {
border-bottom: 1px solid #e0e0e0;
padding-bottom: 16px;
margin-bottom: 16px;
}

.email-header h2 {
margin: 0 0 12px 0;
font-size: 20px;
font-weight: 500;
}

.email-meta {
font-size: 14px;
line-height: 1.6;
}

.email-meta div {
margin-bottom: 4px;
}

.attachments {
margin-bottom: 16px;
padding: 12px;
background: #f9f9f9;
border-radius: 4px;
}

.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}

.attachment-btn {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 14px;
}

.email-body {
margin-bottom: 16px;
}

.email-content {
padding: 16px;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
line-height: 1.6;
font-size: 14px;
}

.plain-content {
white-space: pre-wrap;
font-family: monospace;
}

.email-metadata {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e0e0e0;
}

.headers-content {
margin-top: 12px;
padding: 12px;
background: #f9f9f9;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}
</style>

Python Flask/HTML Example

Server-side rendering with Jinja2 templates:

from flask import Flask, render_template, request, jsonify
from datetime import datetime
import base64
import re
from html import escape
from bleach import clean

app = Flask(__name__)

def parse_email_address(email_str):
"""Extract display name and address from email string"""
match = re.match(r'^(.+?)\s*<(.+?)>$|^(.+)$', email_str)
if match:
if match.group(3):
return {'name': None, 'address': match.group(3)}
return {'name': match.group(1).strip(), 'address': match.group(2).strip()}
return {'name': None, 'address': email_str}

def format_date(date_str):
"""Format ISO date string to readable format"""
try:
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return dt.strftime('%B %d, %Y at %I:%M %p')
except:
return date_str

def sanitize_html(html):
"""Sanitize HTML to prevent XSS"""
return clean(
html,
tags=['p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li', 'img', 'div', 'span'],
attributes={'a': ['href'], 'img': ['src', 'alt']},
strip=True
)

def process_inline_images(html, files):
"""Replace cid: references with data URIs"""
if not files:
return html

for file in files:
if file.get('contentId') and (file.get('disposition') == 'inline' or not file.get('disposition')):
cid = file['contentId'].replace('<', '').replace('>', '')
data_uri = f"data:{file['contentType']};base64,{file['content']}"
html = html.replace(f'cid:{cid}', data_uri)

return html

def get_attachments(files):
"""Filter out inline images, return only attachments"""
if not files:
return []
return [
f for f in files
if f.get('disposition') == 'attachment' or (not f.get('disposition') and not f.get('contentId'))
]

@app.route('/webhooks/incoming-email', methods=['POST'])
def handle_incoming_email():
data = request.json
details = data.get('details', {})

# Process email data
email_data = {
'subject': details.get('subject', '(No Subject)'),
'from': parse_email_address(details.get('from', '')),
'to': parse_email_address(details.get('to', '')),
'cc': details.get('cc'),
'date': format_date(details.get('date', '')),
'body_plain': details.get('body-plain', ''),
'body_html': details.get('body-html'),
'message_id': details.get('message-id'),
'thread_id': details.get('thread-id'),
'in_reply_to': details.get('in-reply-to'),
'attachments': get_attachments(details.get('files', [])),
'files': details.get('files', []),
'headers': details.get('headers', {}),
'mailer_service': details.get('mailer-service'),
}

# Process HTML body with inline images
if email_data['body_html']:
email_data['body_html'] = process_inline_images(
email_data['body_html'],
email_data['files']
)
email_data['body_html'] = sanitize_html(email_data['body_html'])

# Store email (in production, save to database)
# save_email_to_db(email_data)

return jsonify({'received': True})

@app.route('/email/<message_id>')
def view_email(message_id):
# Fetch email from database
# email = get_email_from_db(message_id)

# For demo, use sample data
email = {
'subject': 'Sample Email',
'from': {'name': 'John Doe', 'address': 'john@example.com'},
'to': {'name': None, 'address': 'support@example.com'},
'date': 'January 15, 2024 at 10:40 AM',
'body_html': '<p>This is a sample email.</p>',
'attachments': []
}

return render_template('email_viewer.html', email=email)

if __name__ == '__main__':
app.run(debug=True)

Template file (templates/email_viewer.html):

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ email.subject }}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
padding: 20px;
}

.email-container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}

.email-header {
padding: 24px;
border-bottom: 1px solid #e0e0e0;
}

.email-header h1 {
font-size: 24px;
font-weight: 500;
margin-bottom: 16px;
color: #202124;
}

.email-meta {
font-size: 14px;
color: #5f6368;
line-height: 1.8;
}

.email-meta strong {
color: #202124;
min-width: 80px;
display: inline-block;
}

.attachments {
padding: 16px 24px;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
}

.attachments strong {
display: block;
margin-bottom: 12px;
color: #202124;
}

.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

.attachment-btn {
padding: 8px 16px;
background: white;
border: 1px solid #dadce0;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}

.attachment-btn:hover {
background: #f8f9fa;
}

.email-body {
padding: 24px;
}

.email-content {
line-height: 1.6;
font-size: 14px;
color: #202124;
}

.email-content.html-content {
/* Styles for HTML content */
}

.email-content.plain-content {
white-space: pre-wrap;
font-family: 'Courier New', monospace;
}

.email-footer {
padding: 16px 24px;
border-top: 1px solid #e0e0e0;
background: #f8f9fa;
font-size: 12px;
color: #5f6368;
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h1>{{ email.subject }}</h1>
<div class="email-meta">
<div>
<strong>From:</strong>
{% if email.from.name %}
{{ email.from.name }} &lt;{{ email.from.address }}&gt;
{% else %}
{{ email.from.address }}
{% endif %}
</div>
<div>
<strong>To:</strong>
{% if email.to.name %}
{{ email.to.name }} &lt;{{ email.to.address }}&gt;
{% else %}
{{ email.to.address }}
{% endif %}
</div>
{% if email.cc %}
<div>
<strong>CC:</strong> {{ email.cc }}
</div>
{% endif %}
<div>
<strong>Date:</strong> {{ email.date }}
</div>
</div>
</div>

{% if email.attachments %}
<div class="attachments">
<strong>Attachments ({{ email.attachments|length }}):</strong>
<div class="attachment-list">
{% for file in email.attachments %}
<button class="attachment-btn" onclick="downloadAttachment('{{ file.filename }}', '{{ file.content }}', '{{ file.contentType }}')">
📎 {{ file.filename }}
</button>
{% endfor %}
</div>
</div>
{% endif %}

<div class="email-body">
{% if email.body_html %}
<div class="email-content html-content">
{{ email.body_html|safe }}
</div>
{% else %}
<div class="email-content plain-content">
{{ email.body_plain }}
</div>
{% endif %}
</div>

<div class="email-footer">
<div><strong>Message-ID:</strong> {{ email.message_id }}</div>
{% if email.thread_id %}
<div><strong>Thread-ID:</strong> {{ email.thread_id }}</div>
{% endif %}
</div>
</div>

<script>
function downloadAttachment(filename, base64Content, contentType) {
const binaryString = atob(base64Content);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: contentType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
</body>
</html>

Install dependencies:

pip install flask bleach

Key Security Considerations

  1. HTML Sanitization: Always sanitize HTML content to prevent XSS attacks

    // Use DOMPurify or similar library
    import DOMPurify from 'dompurify';
    const safeHtml = DOMPurify.sanitize(email['body-html']);
  2. Content Security Policy: Set CSP headers to prevent inline script execution

    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https:;">
  3. Attachment Validation: Validate file types and sizes before allowing downloads

    const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
    const ALLOWED_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];

    if (file.size > MAX_FILE_SIZE || !ALLOWED_TYPES.includes(file.contentType)) {
    // Reject or warn user
    }
  4. Email Address Validation: Validate and escape email addresses before displaying

    function escapeEmail(email) {
    return email.replace(/[<>]/g, '').replace(/"/g, '&quot;');
    }

Threading and Conversation View

To group emails into conversations (like Gmail's conversation view):

function groupEmailsByThread(emails) {
const threads = new Map();

emails.forEach(email => {
// Use thread-id if available, otherwise use message-id as fallback
const threadId = email['thread-id'] || email['message-id'];

if (!threads.has(threadId)) {
threads.set(threadId, {
threadId,
messages: [],
subject: email.subject,
participants: new Set(),
lastMessageDate: email.date
});
}

const thread = threads.get(threadId);
thread.messages.push(email);
thread.participants.add(email.from);

// Update last message date
if (new Date(email.date) > new Date(thread.lastMessageDate)) {
thread.lastMessageDate = email.date;
}
});

return Array.from(threads.values()).sort((a, b) =>
new Date(b.lastMessageDate) - new Date(a.lastMessageDate)
);
}

// Usage
const conversations = groupEmailsByThread(incomingEmails);
conversations.forEach(thread => {
console.log(`Thread: ${thread.subject} (${thread.messages.length} messages)`);
thread.messages.forEach(msg => {
console.log(` - ${msg.from}: ${msg.subject}`);
});
});

Understanding MIME Parts and Content-Types

When processing incoming emails, you'll encounter various MIME parts with different Content-Type values. Understanding how these are handled is crucial for proper email processing.

MIME Parts Overview

Email messages are structured using MIME (Multipurpose Internet Mail Extensions). Each part of an email has:

  • Content-Type: Defines what kind of content it is (e.g., text/html, image/jpeg)
  • Content-Disposition: Suggests how to display it (inline or attachment)
  • Content-ID: Optional identifier for referencing (used with cid: in HTML)

Standard MIME Parts

Text Content

{
"content-type": "text/plain",
"body-plain": "Plain text email content"
}

{
"content-type": "text/html",
"body-html": "<html>...</html>"
}

{
"content-type": "multipart/alternative"
// Contains multiple versions (plain + HTML)
}

How clients render: Displayed as the main message body.


Inline Images (No Content-Disposition)

Images without Content-Disposition are treated as inline content.

{
"files": [
{
"filename": "logo.png",
"contentType": "image/png",
"content": "base64_encoded_image",
"contentId": "logo@domain.com"
// No disposition = inline
}
]
}

How clients render:

  • Displayed directly in the message body
  • Referenced in HTML using <img src="cid:logo@domain.com">
  • Embedded within the email content

Example HTML Reference:

<img src="cid:logo@domain.com" alt="Company Logo" />

Use cases:

  • Company logos in email signatures
  • Inline diagrams or charts
  • Embedded screenshots

Calendar Events (text/calendar)

iCalendar format files for meeting invitations and events.

{
"files": [
{
"filename": "meeting.ics",
"contentType": "text/calendar",
"content": "QkVHSU46VkNBTEVOREFS...",
"disposition": "inline"
}
]
}

How clients render:

  • Outlook/Gmail: Interactive calendar UI with Accept / Decline / Maybe buttons
  • Apple Mail: Show event details with add to calendar option
  • Mobile clients: One-tap calendar integration

Example parsed content:

BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Team Meeting
DTSTART:20240115T100000Z
DTEND:20240115T110000Z
LOCATION:Conference Room A
END:VEVENT
END:VCALENDAR

Processing tips:

if (file.contentType === 'text/calendar') {
// Parse the iCalendar data
const calendarData = Buffer.from(file.content, 'base64').toString();

// Extract event details
const event = parseICalendar(calendarData);

// Auto-respond or process meeting
if (event.summary.includes('Interview')) {
await processInterviewRequest(event);
}
}

Data Formats (JSON, XML, CSV)

Machine-readable data formats often used for API responses or data exchange.

{
"files": [
{
"filename": "report.json",
"contentType": "application/json",
"content": "eyJkYXRhIjogInZhbHVlIn0="
},
{
"filename": "data.xml",
"contentType": "text/xml",
"content": "PD94bWwgdmVyc2lvbj0iMS4w..."
},
{
"filename": "export.csv",
"contentType": "text/csv",
"content": "bmFtZSxlbWFpbCxhZ2UKSm9o..."
}
]
}

How clients render:

  • Consumer clients (Gmail, Outlook): Hidden or shown as downloadable attachments
  • Developer clients (Thunderbird with plugins): May show as raw text
  • Mobile clients: Typically ignored unless opened explicitly

Processing tips:

// Process structured data
if (file.contentType === 'application/json') {
const jsonData = JSON.parse(
Buffer.from(file.content, 'base64').toString()
);
await processJsonPayload(jsonData);
}

if (file.contentType === 'text/csv') {
const csvData = Buffer.from(file.content, 'base64').toString();
const records = parseCSV(csvData);
await importRecords(records);
}

if (file.contentType === 'text/xml' || file.contentType === 'application/xml') {
const xmlData = Buffer.from(file.content, 'base64').toString();
const parsed = parseXML(xmlData);
await processXMLData(parsed);
}

Common use cases:

  • JSON: API responses, webhook payloads, structured data
  • XML: SOAP responses, RSS feeds, configuration files
  • CSV: Data exports, bulk imports, reports

Digital Signatures

Cryptographic signatures for email authentication and non-repudiation.

{
"files": [
{
"filename": "smime.p7s",
"contentType": "application/pkcs7-signature",
"content": "MIIGPgYJKoZIhvcNAQcC..."
},
{
"filename": "signature.asc",
"contentType": "application/pgp-signature",
"content": "LS0tLS1CRUdJTiBQR1Ag..."
}
]
}

How clients render:

  • S/MIME signatures (application/pkcs7-signature):
    • Outlook: Shows verified sender badge ✓
    • Apple Mail: Displays signature status
    • Thunderbird: Shows security ribbon
  • PGP signatures (application/pgp-signature):
    • Shows trust level and key info
    • Displays verification status
    • Often hidden if automatically validated

Processing tips:

if (file.contentType === 'application/pkcs7-signature') {
// S/MIME signature
const signature = Buffer.from(file.content, 'base64');
const isValid = await verifySmimeSignature(signature, emailContent);

if (isValid) {
console.log('✓ Email signature verified');
// Mark as trusted in your system
}
}

if (file.contentType === 'application/pgp-signature') {
// PGP signature
const signature = Buffer.from(file.content, 'base64').toString();
const verification = await verifyPgpSignature(signature, emailContent);

console.log('PGP Key ID:', verification.keyId);
console.log('Trusted:', verification.trusted);
}

Other Specialized MIME Types

Content-TypePurposeClient Rendering
message/rfc822Forwarded emailDisplayed as nested email with full headers
application/vnd.ms-outlookOutlook-specific dataOnly rendered in Outlook
text/x-vcard or text/vcardContact information (vCard)Add to contacts button
application/pdfPDF documentInline preview or download
text/rtfRich Text FormatRendered with formatting

Content-Disposition Handling

The Content-Disposition header affects how MIME parts are treated:

Inline Content

{
"filename": "diagram.png",
"contentType": "image/png",
"disposition": "inline",
"contentId": "diagram@example.com"
}

Behavior:

  • Embedded in message body
  • Referenced via cid: in HTML
  • Automatically displayed

Attachment

{
"filename": "report.pdf",
"contentType": "application/pdf",
"disposition": "attachment"
}

Behavior:

  • Listed as downloadable attachment
  • Not automatically displayed
  • Shown in attachments section

No Content-Disposition

{
"filename": "data.json",
"contentType": "application/json"
// No disposition specified
}

Behavior:

  • Images: Treated as inline
  • Documents: Varies by client (usually attachment)
  • Data formats: Usually hidden or treated as attachment

Processing Different MIME Types

Here's a complete example of handling various MIME types:

app.post('/webhooks/incoming-email', async (req, res) => {
const { details } = req.body;

// Process files by content type
for (const file of details.files || []) {
const buffer = Buffer.from(file.content, 'base64');

switch (file.contentType) {
// Images (inline or attachment)
case 'image/jpeg':
case 'image/png':
case 'image/gif':
if (!file.disposition || file.disposition === 'inline') {
// Inline image - extract and store
await saveInlineImage(file.contentId, buffer);
} else {
// Image attachment
await saveAttachment(file.filename, buffer);
}
break;

// Calendar events
case 'text/calendar':
const calendarData = buffer.toString();
const event = parseICalendar(calendarData);
await processMeetingInvite(event, details.from);
break;

// Structured data
case 'application/json':
const jsonData = JSON.parse(buffer.toString());
await processJsonData(jsonData);
break;

case 'text/csv':
const csvData = buffer.toString();
await processCsvData(csvData);
break;

case 'text/xml':
case 'application/xml':
const xmlData = buffer.toString();
await processXmlData(xmlData);
break;

// Digital signatures
case 'application/pkcs7-signature':
const isValidSmime = await verifySmimeSignature(buffer);
console.log('S/MIME valid:', isValidSmime);
break;

case 'application/pgp-signature':
const pgpSig = buffer.toString();
const isValidPgp = await verifyPgpSignature(pgpSig);
console.log('PGP valid:', isValidPgp);
break;

// Contact cards
case 'text/vcard':
case 'text/x-vcard':
const vcard = buffer.toString();
await importContact(vcard);
break;

// Documents
case 'application/pdf':
case 'application/msword':
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
await saveDocument(file.filename, buffer);
break;

// Forwarded emails
case 'message/rfc822':
await processForwardedEmail(buffer);
break;

default:
// Unknown or unsupported type
console.log('Unknown MIME type:', file.contentType);
// Save as generic attachment
await saveGenericAttachment(file.filename, buffer);
}
}

res.status(200).json({ received: true });
});

MIME Type Detection Tips

1. Always check contentType first:

const type = file.contentType.toLowerCase();

2. Handle missing Content-Disposition:

const isInline = !file.disposition || file.disposition === 'inline';

3. Look for Content-ID for inline images:

if (file.contentId && type.startsWith('image/')) {
// This is an inline image referenced in HTML
const cid = file.contentId;
// Replace cid: references in body-html
}

4. Use fallback for unknown types:

if (!knownTypes.includes(type)) {
// Treat as generic attachment
await saveAsAttachment(file);
}

Common MIME Type Categories

const MIME_CATEGORIES = {
text: ['text/plain', 'text/html', 'text/csv', 'text/xml', 'text/calendar'],
images: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
documents: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.*'],
data: ['application/json', 'application/xml', 'text/csv'],
signatures: ['application/pkcs7-signature', 'application/pgp-signature'],
contacts: ['text/vcard', 'text/x-vcard'],
calendar: ['text/calendar', 'application/ics'],
email: ['message/rfc822', 'message/partial']
};

function categorizeFile(file) {
const type = file.contentType.toLowerCase();

for (const [category, types] of Object.entries(MIME_CATEGORIES)) {
if (types.some(t => type.includes(t) || new RegExp(t).test(type))) {
return category;
}
}

return 'unknown';
}

Webhook Headers for Outgoing Emails

When you send emails through EDITH and events occur (delivery, open, click), webhook payloads include threading information:

{
"event": "MAIL_DELIVERED",
"details": {
"tracking": {
"time": "2024-01-15T10:35:00Z",
"ip": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"browser_name": "Chrome",
"browser_version": "91.0",
"os": "Windows",
"device_type": "desktop",
"country": "US",
"city": "New York"
},
"ref_id": "01JC3BBW8S9YGX2VNKG5MD7BTA",
"email": ["recipient@example.com"],
"custom_args": {
"order_id": "12345"
},
"tenant_id": 123,
"account_id": 456,
"message_id": "<edith_mailer_550e8400@sparrowmailer.com>",
"thread_id": "18c5a1b2d3e4f5g6"
}
}

Use message_id and thread_id to correlate outgoing emails with incoming replies.


Processing Incoming Emails

Node.js Example (Complete)

const express = require('express');
const { simpleParser } = require('mailparser');
const crypto = require('crypto');

const app = express();
app.use(express.json({ limit: '50mb' })); // Increase for large attachments

// Store message IDs for conversation tracking
const conversationMap = new Map(); // message_id -> conversation_context

app.post('/webhooks/incoming-email', async (req, res) => {
// 1. Verify webhook authenticity
const webhookSecret = process.env.WEBHOOK_SECRET;
if (req.headers['x-webhook-secret'] !== webhookSecret) {
return res.status(401).json({ error: 'Unauthorized' });
}

try {
const { event, details, success } = req.body;

if (!success || event !== 'INCOMING_EMAIL') {
console.log('Skipping non-incoming-email event or failed payload');
return res.status(200).json({ received: true });
}

// 2. Extract key information
const {
subject,
from,
to,
'message-id': messageId,
'in-reply-to': inReplyTo,
'thread-id': threadId,
references,
'body-plain': bodyPlain,
'body-html': bodyHtml,
files,
'mailer-service': service
} = details;

console.log('📧 Incoming Email:');
console.log(' From:', from);
console.log(' To:', to);
console.log(' Subject:', subject);
console.log(' Message-ID:', messageId);
console.log(' Thread-ID:', threadId);
console.log(' In-Reply-To:', inReplyTo);

// 3. Check if this is a reply to a previous conversation
const isReply = !!inReplyTo;
let conversationContext = null;

if (isReply) {
// Look up original conversation
conversationContext = conversationMap.get(inReplyTo);
console.log(' 📎 This is a reply to:', inReplyTo);
console.log(' 📋 Conversation context:', conversationContext);
}

// 4. Process attachments
if (files && files.length > 0) {
for (const file of files) {
console.log(' 📎 Attachment:', file.filename, `(${file.contentType})`);

// Decode base64 and save or process
const buffer = Buffer.from(file.content, 'base64');
// await saveAttachment(file.filename, buffer);
}
}

// 5. Respond immediately (don't block webhook)
res.status(200).json({ received: true });

// 6. Process email asynchronously
await processEmailAsync({
from,
to,
subject,
bodyPlain,
bodyHtml,
messageId,
threadId,
inReplyTo,
isReply,
conversationContext,
files,
service
});

} catch (error) {
console.error('❌ Error processing email:', error);
res.status(500).json({ error: 'Processing failed' });
}
});

async function processEmailAsync(email) {
// Your business logic here
console.log('🔄 Processing email asynchronously...');

if (email.isReply && email.conversationContext) {
// Handle reply to existing conversation
await handleReply(email);
} else {
// Handle new conversation
await handleNewEmail(email);
}

// Store for future correlation
if (email.messageId) {
conversationMap.set(email.messageId, {
from: email.from,
subject: email.subject,
threadId: email.threadId,
timestamp: new Date()
});
}
}

async function handleReply(email) {
// Update ticket/conversation in your system
console.log(' ✅ Updating existing conversation:', email.conversationContext);
// await ticketSystem.addReply(email.conversationContext.ticketId, email.bodyPlain);
}

async function handleNewEmail(email) {
// Create new ticket/conversation
console.log(' 🆕 Creating new conversation');
// const ticket = await ticketSystem.create({
// from: email.from,
// subject: email.subject,
// body: email.bodyPlain,
// messageId: email.messageId,
// threadId: email.threadId
// });
}

app.listen(3000, () => {
console.log('🚀 Inbound email webhook server running on port 3000');
});

Python Flask Example (Complete)

from flask import Flask, request, jsonify
import base64
import email
from email.parser import Parser
from datetime import datetime
import os

app = Flask(__name__)

# Conversation tracking
conversation_map = {}

@app.route('/webhooks/incoming-email', methods=['POST'])
def handle_incoming():
# 1. Verify webhook
webhook_secret = os.environ.get('WEBHOOK_SECRET')
if request.headers.get('X-Webhook-Secret') != webhook_secret:
return jsonify({'error': 'Unauthorized'}), 401

try:
data = request.json
event = data.get('event')
details = data.get('details', {})
success = data.get('success', False)

if not success or event != 'INCOMING_EMAIL':
return jsonify({'received': True}), 200

# 2. Extract information
subject = details.get('subject')
from_addr = details.get('from')
to_addr = details.get('to')
message_id = details.get('message-id')
in_reply_to = details.get('in-reply-to')
thread_id = details.get('thread-id')
body_plain = details.get('body-plain')
body_html = details.get('body-html')
files = details.get('files', [])

print(f'📧 Incoming Email:')
print(f' From: {from_addr}')
print(f' To: {to_addr}')
print(f' Subject: {subject}')
print(f' Message-ID: {message_id}')
print(f' Thread-ID: {thread_id}')

# 3. Check if reply
is_reply = bool(in_reply_to)
conversation_context = None

if is_reply:
conversation_context = conversation_map.get(in_reply_to)
print(f' 📎 Reply to: {in_reply_to}')
if conversation_context:
print(f' 📋 Context: {conversation_context}')

# 4. Process attachments
for file in files:
filename = file.get('filename')
content_type = file.get('contentType')
content_b64 = file.get('content')

print(f' 📎 Attachment: {filename} ({content_type})')

# Decode and save
# content_bytes = base64.b64decode(content_b64)
# save_attachment(filename, content_bytes)

# 5. Respond immediately
response = jsonify({'received': True})

# 6. Process async (in production, use Celery/RQ)
process_email_async(details, is_reply, conversation_context)

return response, 200

except Exception as e:
print(f'❌ Error: {e}')
return jsonify({'error': str(e)}), 500

def process_email_async(details, is_reply, context):
"""Process email asynchronously"""
print('🔄 Processing email asynchronously...')

if is_reply and context:
handle_reply(details, context)
else:
handle_new_email(details)

# Store for correlation
message_id = details.get('message-id')
if message_id:
conversation_map[message_id] = {
'from': details.get('from'),
'subject': details.get('subject'),
'thread_id': details.get('thread-id'),
'timestamp': datetime.now().isoformat()
}

def handle_reply(details, context):
"""Handle reply to existing conversation"""
print(f' ✅ Updating conversation: {context}')
# Update ticket/conversation in your system

def handle_new_email(details):
"""Handle new email conversation"""
print(' 🆕 Creating new conversation')
# Create new ticket/conversation

if __name__ == '__main__':
app.run(port=3000, debug=True)

DNS Configuration for Inbound Email

To receive emails, configure your domain's MX records:

Basic Configuration

TypeNameValuePriority
MXinbound.yourcompany.commx.edith.example.com10
inbound.yourcompany.com.  MX  10  mx1.edith.example.com.
inbound.yourcompany.com. MX 20 mx2.edith.example.com.
inbound.yourcompany.com. MX 30 mx3.edith.example.com.

Lower priority numbers are tried first.


Best Practices

1. Use a Subdomain for Inbound

Use a dedicated subdomain (e.g., inbound.yourcompany.com) to:

  • ✅ Separate inbound from outbound email
  • ✅ Easier management and troubleshooting
  • ✅ Reduce risk to your main domain reputation
  • ✅ Cleaner routing rules

2. Validate Webhook Authenticity

Always verify webhooks are from EDITH:

// Using secret header
if (req.headers['x-webhook-secret'] !== process.env.WEBHOOK_SECRET) {
return res.status(401).send('Unauthorized');
}

// Or using HMAC signature (if implemented)
const signature = crypto
.createHmac('sha256', SECRET)
.update(JSON.stringify(req.body))
.digest('hex');

if (req.headers['x-webhook-signature'] !== signature) {
return res.status(401).send('Invalid signature');
}

3. Handle Attachments Carefully

// Scan attachments
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB

for (const file of files) {
// Size check
const buffer = Buffer.from(file.content, 'base64');
if (buffer.length > MAX_ATTACHMENT_SIZE) {
console.warn('Attachment too large:', file.filename);
continue;
}

// Virus scan (pseudo-code)
// await virusScanner.scan(buffer);

// Store securely
// await storage.save(`attachments/${sanitize(file.filename)}`, buffer);
}

4. Implement Rate Limiting

const rateLimit = require('express-rate-limit');

const emailLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute per IP
message: 'Too many requests'
});

app.use('/webhooks/incoming-email', emailLimiter);

5. Queue for Async Processing

const Queue = require('bull');
const emailQueue = new Queue('incoming-emails');

app.post('/webhooks/incoming-email', (req, res) => {
// Respond immediately
res.status(200).json({ received: true });

// Queue for processing
emailQueue.add(req.body, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
}
});
});

// Process queue
emailQueue.process(async (job) => {
await processEmail(job.data);
});

6. Track Conversations with Thread-ID and Message-ID

// Store mapping
const conversationStore = new Map();

function trackConversation(email) {
const key = email.threadId || email.messageId;

if (!conversationStore.has(key)) {
conversationStore.set(key, {
messages: [],
participants: new Set(),
created: new Date()
});
}

const conversation = conversationStore.get(key);
conversation.messages.push({
messageId: email.messageId,
from: email.from,
timestamp: email.date,
subject: email.subject
});
conversation.participants.add(email.from);
}

7. Use Wildcard Wisely

Enable wildcard for:

  • ✅ Ticketing systems with dynamic addresses
  • ✅ Testing environments
  • ✅ Email parsing services

Avoid wildcard for:

  • ❌ Production domains with security concerns
  • ❌ Systems with limited processing capacity
  • ❌ When you need strict control over accepted addresses

8. Monitor Webhook Health

// Track webhook success/failure
const webhookStats = {
success: 0,
failed: 0,
lastSuccess: null,
lastFailure: null
};

app.post('/webhooks/incoming-email', async (req, res) => {
try {
await processEmail(req.body);
webhookStats.success++;
webhookStats.lastSuccess = new Date();
res.status(200).json({ received: true });
} catch (error) {
webhookStats.failed++;
webhookStats.lastFailure = new Date();

// Alert if failure rate exceeds threshold
const totalRequests = webhookStats.success + webhookStats.failed;
const failureRate = webhookStats.failed / totalRequests;

if (failureRate > 0.1) { // 10% failure rate
alertOps('High webhook failure rate:', failureRate);
}

throw error;
}
});

Common Errors

ErrorCauseSolution
Invalid PayloadMalformed JSON requestCheck request structure
Domain not foundDomain not configuredAdd domain via /v1/inbound/relay_webhook
Invalid username formatUsername validation failedCheck allowed characters (alphanumeric, ., _, +, -)
Precondition FailedDomain not verifiedVerify domain DNS records
Username already existsDuplicate usernameUse unique usernames per domain
Webhook URL unreachableWebhook endpoint down or wrong URLVerify endpoint is accessible
Authentication failedMissing or invalid webhook secretCheck header configuration