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:
- Email sent to
username@yourdomain.com - EDITH receives via MX records
- Email parsed and validated against routing rules
- Webhook notification sent to your endpoint
- 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:
- ✅ Add your domain to EDITH (see Domain Management Guide)
- ✅ Verify domain ownership via DNS records
- ✅ Configure MX records to point to EDITH
- ✅ 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→ ✅ Acceptedsupport@inbound.example.com→ ✅ Acceptedrandomstring@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-Toheader 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
| Setting | Wildcard | Username Match | Strict Replies | Result |
|---|---|---|---|---|
| Scenario 1 | true | N/A | N/A | ✅ Accept all |
| Scenario 2 | false | No match | N/A | ❌ Reject |
| Scenario 3 | false | Match | false | ✅ Accept |
| Scenario 4 | false | Match | true + Is Reply | ✅ Accept |
| Scenario 5 | false | Match | true + 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
| Field | Type | Required | Description |
|---|---|---|---|
domain | string | ✅ Yes | Your verified domain for receiving emails (e.g., inbound.yourcompany.com) |
url | string | ✅ Yes | Webhook URL to receive incoming email notifications. Must be HTTPS and publicly accessible. |
method | string | ✅ Yes | HTTP method: "GET", "POST", "PUT", "DELETE", "PATCH". Recommended: "POST" |
headers | object | No | Custom 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
| Parameter | Type | Required | Description |
|---|---|---|---|
domain | string | ✅ Yes | The domain name to update (URL encoded if necessary) |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | ✅ Yes* | Updated webhook URL |
method | string | No | Updated HTTP method |
headers | object | No | Updated headers (replaces existing) |
wildcard | boolean | No | Enable (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
| Field | Type | Required | Description |
|---|---|---|---|
user_name | string | ✅ Yes | The local part of the email address (before @). Must be 2-64 characters. Allowed: a-z, A-Z, 0-9, ., _, +, - |
domain | string | ✅ Yes | The 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:
supportjohn.doeorders+trackinghelp_deskteam-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
| Parameter | Type | Required | Description |
|---|---|---|---|
username | string | ✅ Yes | The full email address (e.g., support@inbound.yourcompany.com) |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
domain | string | ✅ Yes | The domain for this email address |
active | boolean | No | Enable (true) or disable (false) this email address |
strict_replies | boolean | No | Enable 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
| Field | Purpose | Provider Support |
|---|---|---|
| Message-ID | Unique identifier for a single email message | All providers |
| Thread-ID | Groups related messages in a conversation | Gmail, Outlook |
| In-Reply-To | References the Message-ID of the email being replied to | All providers |
| References | Chain of Message-IDs in the conversation thread | All 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
| Field | Type | Description |
|---|---|---|
subject | string | Email subject line |
from | string | Sender email address (may include display name) |
reply-to | string | Reply-to address if different from sender |
to | string | Primary recipient(s) |
cc | string | Carbon copy recipient(s) |
bcc | string | Blind carbon copy recipient(s) (rarely populated) |
date | string | Email send date in RFC3339 format |
recipient | string | The 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-plain | string | Plain text version of the email body |
body-html | string | HTML version of the email body |
message-id | string | Unique identifier for this email message |
in-reply-to | string | Message-ID of the email being replied to |
references | string | Space-separated list of Message-IDs in the thread |
thread-id | string | Thread/conversation ID (Gmail, Outlook) |
content-type | string | MIME content type |
user-agent | string | Email client used by sender |
headers | object | All email headers as key-value pairs |
files | array | Attachments (base64 encoded) |
mailer-id | string | EDITH configuration ID that received this email |
mailer-service | string | Email service provider: "gmail", "outlook", "imap", etc. |
label-ids | array | Email labels/folders (e.g., ["INBOX"], ["SENT"]) |
error | string | Error message (only present when success: false) |
webhook-id | string | EDITH 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:
| Field | Type | Description |
|---|---|---|
error | string | Error message describing what went wrong |
mailer-id | string | EDITH configuration ID that attempted to process the email |
webhook-id | string | Webhook 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:
| Field | Type | Description |
|---|---|---|
filename | string | Original filename (may be empty for inline images) |
contentType | string | MIME content type (e.g., "image/png", "application/pdf") |
content | string | Base64-encoded file content (always base64 in JSON payload) |
disposition | string | "attachment" (downloadable) or "inline" (embedded), may be empty |
contentId | string | Content-ID for inline images (used with cid: in HTML), may be empty |
contentLocation | string | Original URL/location of the content (if provided), may be empty |
size | number | File size in bytes (optional, may not be present) |
Field Availability:
filename: Usually present, but may be empty for inline images without explicit filenamedisposition: Present for attachments, may be empty for inline imagescontentId: Only present for inline images referenced in HTML bodycontentLocation: 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 subjectbody-html: Empty if email is plain text onlybody-plain: Empty if email is HTML only (rare, but possible)cc,bcc: Empty strings if not presentreply-to: Empty if same asfromor not specifiedin-reply-to: Empty if not a replyreferences: Empty if not part of a threadthread-id: Empty for non-Gmail/Outlook providers (IMAP, SparkPost)user-agent: May be empty if not provided by senderrecipient: May be empty for some email providers (checktofield as fallback)content-type: May be empty, defaults totext/plainif 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/plainMIME 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/htmlMIME 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:
- If
body-htmlexists and is not empty: Use HTML body (sanitize first!) - Else if
body-plainexists: Use plain text body - 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
| Encoding | Description | How EDITH Handles It |
|---|---|---|
7bit | ASCII text (no encoding) | Content provided as-is |
8bit | 8-bit characters | Content provided as-is |
quoted-printable | Printable ASCII with escape sequences | Automatically decoded to UTF-8 |
base64 | Base64 encoding | Automatically decoded to binary/text |
binary | Raw binary data | Provided 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
-
EDITH decodes automatically: All content in
body-plain,body-html, andfiles[].contentis already decoded. You don't need to decode it again. -
Base64 content in files: The
files[].contentfield 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. -
Character encoding: Check
Content-Typeheader 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:
dispositionfield is"attachment"or missingcontentIdis 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:
dispositionis"inline"or missingcontentIdis 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 Type | Description | Rendering |
|---|---|---|
application/pdf | PDF documents | Show preview or download button |
application/msword | Word documents (.doc) | Download only (preview requires conversion) |
application/vnd.openxmlformats-officedocument.wordprocessingml.document | Word documents (.docx) | Download only |
application/vnd.ms-excel | Excel spreadsheets (.xls) | Download only |
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | Excel spreadsheets (.xlsx) | Download only |
application/vnd.ms-powerpoint | PowerPoint (.ppt) | Download only |
application/vnd.openxmlformats-officedocument.presentationml.presentation | PowerPoint (.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 Type | Description | Rendering |
|---|---|---|
image/jpeg, image/jpg | JPEG images | Display inline |
image/png | PNG images | Display inline |
image/gif | GIF images | Display inline (animated GIFs supported) |
image/webp | WebP images | Display inline |
image/svg+xml | SVG vector graphics | Display inline (sanitize first!) |
image/bmp | Bitmap images | Display inline |
image/tiff | TIFF images | Download 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 Type | Description | Rendering |
|---|---|---|
application/zip | ZIP archives | Download only |
application/x-tar | TAR archives | Download only |
application/gzip | GZIP compressed files | Download only |
application/vnd.rar | RAR archives | Download only |
application/x-7z-compressed | 7-Zip archives | Download only |
Security note: Always scan archives before extraction in production!
Audio/Video
| Content Type | Description | Rendering |
|---|---|---|
audio/mpeg, audio/mp3 | MP3 audio | Show audio player |
audio/wav | WAV audio | Show audio player |
audio/ogg | OGG audio | Show audio player |
video/mp4 | MP4 video | Show video player |
video/x-msvideo | AVI video | Show video player |
video/quicktime | MOV video | Show video player |
video/webm | WebM video | Show 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 Type | Description | Rendering |
|---|---|---|
text/calendar | iCalendar events (.ics) | Parse and show event details |
text/vcard, text/x-vcard | Contact cards (.vcf) | Show "Add to Contacts" button |
application/json | JSON data | Show formatted JSON viewer |
text/csv | CSV data | Show table view or download |
text/xml, application/xml | XML data | Show formatted XML viewer |
message/rfc822 | Forwarded email | Parse 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 labelsrecipient: Usually populated from theTofield- 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 theTofield- 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, checktofield- Headers: All IMAP headers preserved as-is
SparkPost (mailer-service: "sparkpost")
thread-id: Empty (not provided by SparkPost)label-ids: Usually["INBOX"]or emptyrecipient: Populated fromRcptToin 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 versionbody-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 OKquickly (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
- HTML vs Plain Text: Prefer HTML body when available, fallback to plain text
- Security: Always sanitize HTML content to prevent XSS attacks
- Attachments: Display attachments with download links and previews when possible
- Threading: Group emails by
thread-idorreferencesfor conversation view - Inline Images: Handle
cid:references for embedded images - Date Formatting: Parse and format dates in user's timezone
- 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' }}><{fromParsed.address}></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 }} <{{ email.from.address }}>
{% else %}
{{ email.from.address }}
{% endif %}
</div>
<div>
<strong>To:</strong>
{% if email.to.name %}
{{ email.to.name }} <{{ email.to.address }}>
{% 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
-
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']); -
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:;"> -
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
} -
Email Address Validation: Validate and escape email addresses before displaying
function escapeEmail(email) {
return email.replace(/[<>]/g, '').replace(/"/g, '"');
}
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 (
inlineorattachment) - 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-Type | Purpose | Client Rendering |
|---|---|---|
message/rfc822 | Forwarded email | Displayed as nested email with full headers |
application/vnd.ms-outlook | Outlook-specific data | Only rendered in Outlook |
text/x-vcard or text/vcard | Contact information (vCard) | Add to contacts button |
application/pdf | PDF document | Inline preview or download |
text/rtf | Rich Text Format | Rendered 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
| Type | Name | Value | Priority |
|---|---|---|---|
| MX | inbound.yourcompany.com | mx.edith.example.com | 10 |
Multiple MX Records (Recommended for Redundancy)
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
| Error | Cause | Solution |
|---|---|---|
Invalid Payload | Malformed JSON request | Check request structure |
Domain not found | Domain not configured | Add domain via /v1/inbound/relay_webhook |
Invalid username format | Username validation failed | Check allowed characters (alphanumeric, ., _, +, -) |
Precondition Failed | Domain not verified | Verify domain DNS records |
Username already exists | Duplicate username | Use unique usernames per domain |
Webhook URL unreachable | Webhook endpoint down or wrong URL | Verify endpoint is accessible |
Authentication failed | Missing or invalid webhook secret | Check header configuration |
Related Endpoints
- Domain Management - Set up receiving domain and verify DNS
- Webhooks - Configure webhook endpoints and event handling
- IMAP Configuration - Alternative inbound via IMAP monitoring
- Email Sending - Send emails with threading headers for replies