diff --git a/apps/api/package.json b/apps/api/package.json index bf3f269..134647c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/controllers/Webhooks.ts b/apps/api/src/controllers/Webhooks.ts index 5550841..b13fb08 100644 --- a/apps/api/src/controllers/Webhooks.ts +++ b/apps/api/src/controllers/Webhooks.ts @@ -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, diff --git a/apps/wiki/content/docs/guides/receiving-emails.mdx b/apps/wiki/content/docs/guides/receiving-emails.mdx index 64d5687..6923fe2 100644 --- a/apps/wiki/content/docs/guides/receiving-emails.mdx +++ b/apps/wiki/content/docs/guides/receiving-emails.mdx @@ -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 `support@yourdomain.com`: + +1. **Trigger**: `email.received` +2. **Condition**: Check if `{{event.to}}` equals `support@yourdomain.com` +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 - - 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. - - - **Catch-all addresses**: Plunk receives emails sent to any address at your verified domain (e.g., `anything@yourdomain.com`). 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. diff --git a/apps/wiki/content/docs/guides/webhooks.mdx b/apps/wiki/content/docs/guides/webhooks.mdx index 6a24e8c..4612f31 100644 --- a/apps/wiki/content/docs/guides/webhooks.mdx +++ b/apps/wiki/content/docs/guides/webhooks.mdx @@ -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": ["support@yourdomain.com"], "hasContent": true, + "body": "This is the email body content...", "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 | diff --git a/yarn.lock b/yarn.lock index 171638b..c9fec69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6710,7 +6710,7 @@ __metadata: languageName: node linkType: hard -"@types/mailparser@npm:^3.4.4": +"@types/mailparser@npm:^3.4.4, @types/mailparser@npm:^3.4.6": version: 3.4.6 resolution: "@types/mailparser@npm:3.4.6" dependencies: @@ -7871,6 +7871,7 @@ __metadata: "@types/express": "npm:^5.0.5" "@types/helmet": "npm:^4.0.0" "@types/jsonwebtoken": "npm:^9.0.6" + "@types/mailparser": "npm:^3.4.6" "@types/morgan": "npm:^1.9.9" "@types/multer": "npm:^2.0.0" "@types/signale": "npm:^1.4.7" @@ -7889,6 +7890,7 @@ __metadata: ioredis: "npm:^5.8.2" jsonwebtoken: "npm:^9.0.2" mailchecker: "npm:^6.0.19" + mailparser: "npm:^3.9.8" morgan: "npm:^1.10.0" multer: "npm:^2.1.1" signale: "npm:^1.4.0" @@ -12996,6 +12998,18 @@ __metadata: languageName: node 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": version: 2.1.1 resolution: "libqp@npm:2.1.1" @@ -13400,6 +13414,24 @@ __metadata: languageName: node 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": version: 4.0.0 resolution: "make-dir@npm:4.0.0" @@ -14832,6 +14864,13 @@ __metadata: languageName: node 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": version: 9.0.0 resolution: "nopt@npm:9.0.0"