feat: Enhance email processing to include parsed HTML body content in inbound email records

Closes #342
This commit is contained in:
Dries Augustyns
2026-04-13 17:58:52 +02:00
parent ae64c2dbb5
commit e3395083db
5 changed files with 116 additions and 27 deletions
+2
View File
@@ -36,6 +36,7 @@
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"mailchecker": "^6.0.19",
"mailparser": "^3.9.8",
"morgan": "^1.10.0",
"multer": "^2.1.1",
"signale": "^1.4.0",
@@ -48,6 +49,7 @@
"@types/express": "^5.0.5",
"@types/helmet": "^4.0.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/mailparser": "^3.4.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^2.0.0",
"@types/signale": "^1.4.7",
+21 -3
View File
@@ -2,6 +2,7 @@ import {Controller, Post} from '@overnightjs/core';
import type {Prisma} from '@plunk/db';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import type {Request, Response} from 'express';
import {simpleParser} from 'mailparser';
import signale from 'signale';
import type Stripe from 'stripe';
@@ -146,6 +147,21 @@ export class Webhooks {
const senderEmail = body.mail?.source;
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
// Parse email content if available
let htmlBody: string | undefined;
if (body.content) {
try {
const parsed = await simpleParser(body.content);
// Prefer HTML body, fallback to text if no HTML available
htmlBody = parsed.html ? String(parsed.html) : parsed.text || undefined;
signale.info('[WEBHOOK] Email content parsed successfully');
} catch (parseError) {
signale.error('[WEBHOOK] Failed to parse email content:', parseError);
// Continue processing without content
}
}
// Process inbound email for each project that has this domain verified
for (const domainRecord of domainRecords) {
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
@@ -171,13 +187,13 @@ export class Webhooks {
);
}
// Create an Email record for tracking (no actual email content since it's inbound)
// Create an Email record for tracking with parsed content
const inboundEmail = await prisma.email.create({
data: {
projectId: domainRecord.projectId,
contactId: contact!.id,
subject: body.mail?.commonHeaders?.subject || '(No subject)',
body: '', // Inbound emails don't have body content in our system
body: htmlBody || '', // Store HTML body in the body field
from: recipientEmail, // The recipient address that received the email
sourceType: EmailSourceType.INBOUND,
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
@@ -197,7 +213,7 @@ export class Webhooks {
);
}
// Prepare event data with all inbound email details
// Prepare event data with all inbound email details including body content
const eventData = {
messageId: body.mail?.messageId,
from: senderEmail,
@@ -207,6 +223,8 @@ export class Webhooks {
timestamp: body.mail?.timestamp,
recipients: body.receipt?.recipients,
hasContent: !!body.content,
// Email body content
body: htmlBody,
// Security verdicts
spamVerdict: body.receipt?.spamVerdict?.status,
virusVerdict: body.receipt?.virusVerdict?.status,
@@ -65,24 +65,58 @@ Create a new workflow and use `email.received` as the trigger event. This workfl
The `email.received` event includes the following data that you can use in your workflow steps:
| Field | Type | Description |
| ---------------------- | -------- | ---------------------------------------------- |
| `messageId` | string | Unique identifier for the received message |
| `from` | string | Email address of the sender |
| `fromHeader` | string | Full "From" header including display name |
| `to` | string | Primary recipient email address |
| `subject` | string | Email subject line |
| `timestamp` | string | ISO 8601 timestamp when the email was received |
| `recipients` | string[] | All recipient email addresses |
| `hasContent` | boolean | Whether the email body was captured |
| `spamVerdict` | string | Spam check result (e.g., "PASS", "FAIL") |
| `virusVerdict` | string | Virus scan result (e.g., "PASS", "FAIL") |
| `spfVerdict` | string | SPF authentication result |
| `dkimVerdict` | string | DKIM authentication result |
| `dmarcVerdict` | string | DMARC authentication result |
| `processingTimeMillis` | number | Time taken to process the email |
| Field | Type | Description |
| ---------------------- | -------- | ------------------------------------------------- |
| `messageId` | string | Unique identifier for the received message |
| `from` | string | Email address of the sender |
| `fromHeader` | string | Full "From" header including display name |
| `to` | string | Primary recipient email address |
| `subject` | string | Email subject line |
| `timestamp` | string | ISO 8601 timestamp when the email was received |
| `recipients` | string[] | All recipient email addresses |
| `hasContent` | boolean | Whether the email body was captured |
| `body` | string | HTML body of the email (or plain text if no HTML) |
| `spamVerdict` | string | Spam check result (e.g., "PASS", "FAIL") |
| `virusVerdict` | string | Virus scan result (e.g., "PASS", "FAIL") |
| `spfVerdict` | string | SPF authentication result |
| `dkimVerdict` | string | DKIM authentication result |
| `dmarcVerdict` | string | DMARC authentication result |
| `processingTimeMillis` | number | Time taken to process the email |
You can access these fields in your workflow using variable syntax, for example: `{{event.subject}}` or `{{event.from}}`.
You can access these fields in your workflow using variable syntax, for example: `{{event.subject}}`, `{{event.from}}`, or `{{event.body}}`.
### Example: Auto-reply workflow
Here's a simple workflow that sends an automatic reply when an email is received at `[email protected]`:
1. **Trigger**: `email.received`
2. **Condition**: Check if `{{event.to}}` equals `[email protected]`
3. **Send Email**:
- **To**: `{{event.from}}`
- **Subject**: `Re: {{event.subject}}`
- **Body**: `Thank you for your message. We received: "{{event.body}}". We'll get back to you soon!`
This workflow reads the incoming email body and includes it in the auto-reply response.
### Example: Forward to webhook
For more advanced processing (like ticket creation or AI analysis), you can forward the email content to your own API:
1. **Trigger**: `email.received`
2. **Webhook**:
- **URL**: `https://api.example.com/support/tickets`
- **Method**: `POST`
- **Body**:
```json
{
"from": "{{event.from}}",
"subject": "{{event.subject}}",
"body": "{{event.body}}",
"timestamp": "{{event.timestamp}}"
}
```
Your backend receives the full email content and can process it (create a ticket, run AI analysis, etc.).
## Multi-project domains
@@ -96,12 +130,6 @@ This allows you to segment inbound email handling across different projects if n
## Limitations
<Callout title="Email body content" variant="warning">
Currently, the email body content is not stored or made available in the event data. Only metadata (sender, subject,
recipients, timestamps, and security verdicts) is captured. The `hasContent` field indicates whether body content was
present, but the content itself is not accessible in workflows.
</Callout>
- **Catch-all addresses**: Plunk receives emails sent to any address at your verified domain (e.g., `[email protected]`). You can use workflow conditions to route emails based on the `to` field.
- **Attachments**: Email attachments are not currently captured or stored.
- **Email size**: AWS SES has a maximum message size limit of 40 MB for inbound emails.
@@ -324,6 +324,7 @@ This event fires when an email is received at your verified domain. See [Receivi
"timestamp": "2025-01-15T10:30:00.000Z",
"recipients": ["[email protected]"],
"hasContent": true,
"body": "<html><body>This is the email body content...</body></html>",
"spamVerdict": "PASS",
"virusVerdict": "PASS",
"spfVerdict": "PASS",
@@ -343,6 +344,7 @@ This event fires when an email is received at your verified domain. See [Receivi
| `timestamp` | When SES received the email |
| `recipients` | All recipient addresses in the envelope |
| `hasContent` | Whether the email body content is available |
| `body` | HTML body of the email (or plain text if no HTML available) |
| `spamVerdict` | SES spam check result: `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED` |
| `virusVerdict` | SES virus check result |
| `spfVerdict` | SPF authentication result |