feat: Enhance email processing to include parsed HTML body content in inbound email records
Closes #342
This commit is contained in:
@@ -36,6 +36,7 @@
|
|||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mailchecker": "^6.0.19",
|
"mailchecker": "^6.0.19",
|
||||||
|
"mailparser": "^3.9.8",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"signale": "^1.4.0",
|
"signale": "^1.4.0",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.5",
|
||||||
"@types/helmet": "^4.0.0",
|
"@types/helmet": "^4.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/signale": "^1.4.7",
|
"@types/signale": "^1.4.7",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {Controller, Post} from '@overnightjs/core';
|
|||||||
import type {Prisma} from '@plunk/db';
|
import type {Prisma} from '@plunk/db';
|
||||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||||
import type {Request, Response} from 'express';
|
import type {Request, Response} from 'express';
|
||||||
|
import {simpleParser} from 'mailparser';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
@@ -146,6 +147,21 @@ export class Webhooks {
|
|||||||
const senderEmail = body.mail?.source;
|
const senderEmail = body.mail?.source;
|
||||||
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
|
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
|
// Process inbound email for each project that has this domain verified
|
||||||
for (const domainRecord of domainRecords) {
|
for (const domainRecord of domainRecords) {
|
||||||
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
|
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({
|
const inboundEmail = await prisma.email.create({
|
||||||
data: {
|
data: {
|
||||||
projectId: domainRecord.projectId,
|
projectId: domainRecord.projectId,
|
||||||
contactId: contact!.id,
|
contactId: contact!.id,
|
||||||
subject: body.mail?.commonHeaders?.subject || '(No subject)',
|
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
|
from: recipientEmail, // The recipient address that received the email
|
||||||
sourceType: EmailSourceType.INBOUND,
|
sourceType: EmailSourceType.INBOUND,
|
||||||
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
|
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 = {
|
const eventData = {
|
||||||
messageId: body.mail?.messageId,
|
messageId: body.mail?.messageId,
|
||||||
from: senderEmail,
|
from: senderEmail,
|
||||||
@@ -207,6 +223,8 @@ export class Webhooks {
|
|||||||
timestamp: body.mail?.timestamp,
|
timestamp: body.mail?.timestamp,
|
||||||
recipients: body.receipt?.recipients,
|
recipients: body.receipt?.recipients,
|
||||||
hasContent: !!body.content,
|
hasContent: !!body.content,
|
||||||
|
// Email body content
|
||||||
|
body: htmlBody,
|
||||||
// Security verdicts
|
// Security verdicts
|
||||||
spamVerdict: body.receipt?.spamVerdict?.status,
|
spamVerdict: body.receipt?.spamVerdict?.status,
|
||||||
virusVerdict: body.receipt?.virusVerdict?.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:
|
The `email.received` event includes the following data that you can use in your workflow steps:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------------- | -------- | ---------------------------------------------- |
|
| ---------------------- | -------- | ------------------------------------------------- |
|
||||||
| `messageId` | string | Unique identifier for the received message |
|
| `messageId` | string | Unique identifier for the received message |
|
||||||
| `from` | string | Email address of the sender |
|
| `from` | string | Email address of the sender |
|
||||||
| `fromHeader` | string | Full "From" header including display name |
|
| `fromHeader` | string | Full "From" header including display name |
|
||||||
| `to` | string | Primary recipient email address |
|
| `to` | string | Primary recipient email address |
|
||||||
| `subject` | string | Email subject line |
|
| `subject` | string | Email subject line |
|
||||||
| `timestamp` | string | ISO 8601 timestamp when the email was received |
|
| `timestamp` | string | ISO 8601 timestamp when the email was received |
|
||||||
| `recipients` | string[] | All recipient email addresses |
|
| `recipients` | string[] | All recipient email addresses |
|
||||||
| `hasContent` | boolean | Whether the email body was captured |
|
| `hasContent` | boolean | Whether the email body was captured |
|
||||||
| `spamVerdict` | string | Spam check result (e.g., "PASS", "FAIL") |
|
| `body` | string | HTML body of the email (or plain text if no HTML) |
|
||||||
| `virusVerdict` | string | Virus scan result (e.g., "PASS", "FAIL") |
|
| `spamVerdict` | string | Spam check result (e.g., "PASS", "FAIL") |
|
||||||
| `spfVerdict` | string | SPF authentication result |
|
| `virusVerdict` | string | Virus scan result (e.g., "PASS", "FAIL") |
|
||||||
| `dkimVerdict` | string | DKIM authentication result |
|
| `spfVerdict` | string | SPF authentication result |
|
||||||
| `dmarcVerdict` | string | DMARC authentication result |
|
| `dkimVerdict` | string | DKIM authentication result |
|
||||||
| `processingTimeMillis` | number | Time taken to process the email |
|
| `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
|
## Multi-project domains
|
||||||
|
|
||||||
@@ -96,12 +130,6 @@ This allows you to segment inbound email handling across different projects if n
|
|||||||
|
|
||||||
## Limitations
|
## 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.
|
- **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.
|
- **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.
|
- **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",
|
"timestamp": "2025-01-15T10:30:00.000Z",
|
||||||
"recipients": ["[email protected]"],
|
"recipients": ["[email protected]"],
|
||||||
"hasContent": true,
|
"hasContent": true,
|
||||||
|
"body": "<html><body>This is the email body content...</body></html>",
|
||||||
"spamVerdict": "PASS",
|
"spamVerdict": "PASS",
|
||||||
"virusVerdict": "PASS",
|
"virusVerdict": "PASS",
|
||||||
"spfVerdict": "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 |
|
| `timestamp` | When SES received the email |
|
||||||
| `recipients` | All recipient addresses in the envelope |
|
| `recipients` | All recipient addresses in the envelope |
|
||||||
| `hasContent` | Whether the email body content is available |
|
| `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` |
|
| `spamVerdict` | SES spam check result: `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED` |
|
||||||
| `virusVerdict` | SES virus check result |
|
| `virusVerdict` | SES virus check result |
|
||||||
| `spfVerdict` | SPF authentication result |
|
| `spfVerdict` | SPF authentication result |
|
||||||
|
|||||||
@@ -6710,7 +6710,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/mailparser@npm:^3.4.4":
|
"@types/mailparser@npm:^3.4.4, @types/mailparser@npm:^3.4.6":
|
||||||
version: 3.4.6
|
version: 3.4.6
|
||||||
resolution: "@types/mailparser@npm:3.4.6"
|
resolution: "@types/mailparser@npm:3.4.6"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7871,6 +7871,7 @@ __metadata:
|
|||||||
"@types/express": "npm:^5.0.5"
|
"@types/express": "npm:^5.0.5"
|
||||||
"@types/helmet": "npm:^4.0.0"
|
"@types/helmet": "npm:^4.0.0"
|
||||||
"@types/jsonwebtoken": "npm:^9.0.6"
|
"@types/jsonwebtoken": "npm:^9.0.6"
|
||||||
|
"@types/mailparser": "npm:^3.4.6"
|
||||||
"@types/morgan": "npm:^1.9.9"
|
"@types/morgan": "npm:^1.9.9"
|
||||||
"@types/multer": "npm:^2.0.0"
|
"@types/multer": "npm:^2.0.0"
|
||||||
"@types/signale": "npm:^1.4.7"
|
"@types/signale": "npm:^1.4.7"
|
||||||
@@ -7889,6 +7890,7 @@ __metadata:
|
|||||||
ioredis: "npm:^5.8.2"
|
ioredis: "npm:^5.8.2"
|
||||||
jsonwebtoken: "npm:^9.0.2"
|
jsonwebtoken: "npm:^9.0.2"
|
||||||
mailchecker: "npm:^6.0.19"
|
mailchecker: "npm:^6.0.19"
|
||||||
|
mailparser: "npm:^3.9.8"
|
||||||
morgan: "npm:^1.10.0"
|
morgan: "npm:^1.10.0"
|
||||||
multer: "npm:^2.1.1"
|
multer: "npm:^2.1.1"
|
||||||
signale: "npm:^1.4.0"
|
signale: "npm:^1.4.0"
|
||||||
@@ -12996,6 +12998,18 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"libmime@npm:5.3.8":
|
||||||
|
version: 5.3.8
|
||||||
|
resolution: "libmime@npm:5.3.8"
|
||||||
|
dependencies:
|
||||||
|
encoding-japanese: "npm:2.2.0"
|
||||||
|
iconv-lite: "npm:0.7.2"
|
||||||
|
libbase64: "npm:1.3.0"
|
||||||
|
libqp: "npm:2.1.1"
|
||||||
|
checksum: 10c0/4fb65f7c07a9bdb0c0ee25bb80fac94a803807f9ac15a3d6cafc2c93303500d92c8ceb6dfe0ab938c871406fc01cb69a7a457f28b2037c47c283574e8900d8c4
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"libqp@npm:2.1.1":
|
"libqp@npm:2.1.1":
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
resolution: "libqp@npm:2.1.1"
|
resolution: "libqp@npm:2.1.1"
|
||||||
@@ -13400,6 +13414,24 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"mailparser@npm:^3.9.8":
|
||||||
|
version: 3.9.8
|
||||||
|
resolution: "mailparser@npm:3.9.8"
|
||||||
|
dependencies:
|
||||||
|
"@zone-eu/mailsplit": "npm:5.4.8"
|
||||||
|
encoding-japanese: "npm:2.2.0"
|
||||||
|
he: "npm:1.2.0"
|
||||||
|
html-to-text: "npm:9.0.5"
|
||||||
|
iconv-lite: "npm:0.7.2"
|
||||||
|
libmime: "npm:5.3.8"
|
||||||
|
linkify-it: "npm:5.0.0"
|
||||||
|
nodemailer: "npm:8.0.5"
|
||||||
|
punycode.js: "npm:2.3.1"
|
||||||
|
tlds: "npm:1.261.0"
|
||||||
|
checksum: 10c0/7d86b57cb6b676902d4547d573bb40c660b5adf1024b418ddd02a584af3b5a64b20d2153d94ea8b666034939e1f156b823f9f6ee3ddfbd2a2ae61920c7c3fc0a
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"make-dir@npm:^4.0.0":
|
"make-dir@npm:^4.0.0":
|
||||||
version: 4.0.0
|
version: 4.0.0
|
||||||
resolution: "make-dir@npm:4.0.0"
|
resolution: "make-dir@npm:4.0.0"
|
||||||
@@ -14832,6 +14864,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"nodemailer@npm:8.0.5":
|
||||||
|
version: 8.0.5
|
||||||
|
resolution: "nodemailer@npm:8.0.5"
|
||||||
|
checksum: 10c0/5e8450499bd059c56d74ba96fa5f9928de2ecdae0d53c083dba5661d797114c1f9524d30f992d0263cc5a7dcf5a54b9c1d92dc1f766da150c9d0bde7d3798431
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"nopt@npm:^9.0.0":
|
"nopt@npm:^9.0.0":
|
||||||
version: 9.0.0
|
version: 9.0.0
|
||||||
resolution: "nopt@npm:9.0.0"
|
resolution: "nopt@npm:9.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user