Merge pull request #290 from useplunk/inbound

Inbound
This commit is contained in:
Dries Augustyns
2026-02-17 16:56:29 +01:00
committed by GitHub
4 changed files with 412 additions and 144 deletions
+4
View File
@@ -3,6 +3,7 @@ import type {Request, Response} from 'express';
import { import {
API_URI, API_URI,
AWS_SES_REGION,
DASHBOARD_URI, DASHBOARD_URI,
GITHUB_OAUTH_ENABLED, GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED,
@@ -59,6 +60,9 @@ export class Config {
: null, : null,
}, },
}, },
aws: {
sesRegion: AWS_SES_REGION,
},
}); });
} }
} }
+102 -19
View File
@@ -8,6 +8,7 @@ import type Stripe from 'stripe';
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js'; import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
import {stripe} from '../app/stripe.js'; import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {EventService} from '../services/EventService.js'; import {EventService} from '../services/EventService.js';
import {NtfyService} from '../services/NtfyService.js'; import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js'; import {SecurityService} from '../services/SecurityService.js';
@@ -72,25 +73,107 @@ export class Webhooks {
// Check if this is an inbound email notification (SES Receiving) // Check if this is an inbound email notification (SES Receiving)
if (body.notificationType === 'Received') { if (body.notificationType === 'Received') {
signale.info('[WEBHOOK] Received inbound email notification from SES'); signale.info('[WEBHOOK] Received inbound email notification from SES');
signale.info('[WEBHOOK] Inbound email details:', {
messageId: body.mail?.messageId, try {
source: body.mail?.source, // Extract recipient addresses from the inbound email
destination: body.mail?.destination, const recipients = body.receipt?.recipients || [];
recipients: body.receipt?.recipients,
timestamp: body.mail?.timestamp, if (recipients.length === 0) {
subject: body.mail?.commonHeaders?.subject, signale.warn('[WEBHOOK] No recipients found in inbound email');
from: body.mail?.commonHeaders?.from, return res.status(200).json({success: true, message: 'No recipients found'});
to: body.mail?.commonHeaders?.to, }
hasContent: !!body.content,
spamVerdict: body.receipt?.spamVerdict?.status, // For each recipient, identify the domain and create events
virusVerdict: body.receipt?.virusVerdict?.status, for (const recipient of recipients) {
spfVerdict: body.receipt?.spfVerdict?.status, const recipientEmail = recipient as string;
dkimVerdict: body.receipt?.dkimVerdict?.status, const domain = recipientEmail.split('@')[1];
dmarcVerdict: body.receipt?.dmarcVerdict?.status,
}); if (!domain) {
// For now, just log and acknowledge receipt signale.warn('[WEBHOOK] Invalid recipient email format:', recipientEmail);
// Future: process the inbound email (parse, store, trigger workflows, etc.) continue;
return res.status(200).json({success: true, message: 'Inbound email received'}); }
// Find ALL projects that have this domain verified
// A domain can be shared across multiple projects if users are members of both
const domainRecords = await prisma.domain.findMany({
where: {
domain,
verified: true, // Only process emails for verified domains
},
include: {
project: true,
},
});
if (domainRecords.length === 0) {
signale.info(`[WEBHOOK] No verified domain found for: ${domain}`);
continue;
}
signale.info(
`[WEBHOOK] Found ${domainRecords.length} project(s) with verified domain ${domain}. Processing inbound email for all.`,
);
// Extract sender information (same for all projects)
const senderEmail = body.mail?.source;
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
// 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}`);
// Find or create a contact for the sender in this project
let contact;
if (senderEmail) {
contact = await ContactService.upsert(
domainRecord.projectId,
senderEmail,
undefined, // No additional data
true, // Subscribe by default for inbound email senders
);
}
// Prepare event data with all inbound email details
const eventData = {
messageId: body.mail?.messageId,
from: senderEmail,
fromHeader: senderFromHeader,
to: recipientEmail,
subject: body.mail?.commonHeaders?.subject,
timestamp: body.mail?.timestamp,
recipients: body.receipt?.recipients,
hasContent: !!body.content,
// Security verdicts
spamVerdict: body.receipt?.spamVerdict?.status,
virusVerdict: body.receipt?.virusVerdict?.status,
spfVerdict: body.receipt?.spfVerdict?.status,
dkimVerdict: body.receipt?.dkimVerdict?.status,
dmarcVerdict: body.receipt?.dmarcVerdict?.status,
// Processing metadata
processingTimeMillis: body.receipt?.processingTimeMillis,
};
// Create the email.received event (this will trigger workflows)
await EventService.trackEvent(
domainRecord.projectId,
'email.received',
contact?.id,
undefined, // No emailId for inbound emails (they're not sent by us)
eventData,
);
signale.success(
`[WEBHOOK] Created email.received event for ${senderEmail}${recipientEmail} (project: ${domainRecord.project.name})`,
);
}
}
return res.status(200).json({success: true, message: 'Inbound email processed'});
} catch (inboundError) {
signale.error('[WEBHOOK] Error processing inbound email:', inboundError);
// Return 200 to acknowledge receipt even if processing failed
return res.status(200).json({success: true, message: 'Error processing inbound email'});
}
} }
// Handle outbound email event notifications (existing logic) // Handle outbound email event notifications (existing logic)
+303 -125
View File
@@ -3,8 +3,6 @@ import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod'; import {zodResolver} from '@hookform/resolvers/zod';
import {DomainSchemas} from '@plunk/shared'; import {DomainSchemas} from '@plunk/shared';
import { import {
Alert,
AlertDescription,
Badge, Badge,
Button, Button,
Card, Card,
@@ -22,7 +20,8 @@ import {
Input, Input,
} from '@plunk/ui'; } from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion'; import {AnimatePresence, motion} from 'framer-motion';
import {Check, CheckCircle2, Copy, Loader2, RefreshCw, Trash2, XCircle} from 'lucide-react'; import {Check, CheckCircle2, ChevronDown, Copy, Loader2, RefreshCw, Trash2, XCircle} from 'lucide-react';
import {useConfig} from '../lib/hooks/useConfig';
import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains'; import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains';
interface DomainsSettingsProps { interface DomainsSettingsProps {
@@ -34,6 +33,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
const {addDomain} = useAddDomain(); const {addDomain} = useAddDomain();
const {checkVerification} = useCheckDomainVerification(); const {checkVerification} = useCheckDomainVerification();
const {removeDomain} = useRemoveDomain(); const {removeDomain} = useRemoveDomain();
const {data: config} = useConfig();
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -47,6 +47,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
const [cooldownSeconds, setCooldownSeconds] = useState<{[key: string]: number}>({}); const [cooldownSeconds, setCooldownSeconds] = useState<{[key: string]: number}>({});
const [showRemoveDialog, setShowRemoveDialog] = useState(false); const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [domainToRemove, setDomainToRemove] = useState<{id: string; name: string} | null>(null); const [domainToRemove, setDomainToRemove] = useState<{id: string; name: string} | null>(null);
const [expandedDomains, setExpandedDomains] = useState<{[key: string]: boolean}>({});
const form = useForm<{domain: string}>({ const form = useForm<{domain: string}>({
resolver: zodResolver(DomainSchemas.create.omit({projectId: true})), resolver: zodResolver(DomainSchemas.create.omit({projectId: true})),
@@ -86,6 +87,28 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [lastVerificationCheck]); }, [lastVerificationCheck]);
// Auto-expand unverified domains on initial load
useEffect(() => {
if (domains && domains.length > 0) {
const unverifiedDomains = domains
.filter(d => !d.verified)
.reduce(
(acc, d) => {
acc[d.id] = true;
return acc;
},
{} as {[key: string]: boolean},
);
if (Object.keys(unverifiedDomains).length > 0) {
setExpandedDomains(prev => ({
...prev,
...unverifiedDomains,
}));
}
}
}, [domains]);
const showMessage = (type: 'success' | 'error', message: string) => { const showMessage = (type: 'success' | 'error', message: string) => {
if (type === 'success') { if (type === 'success') {
setSuccessMessage(message); setSuccessMessage(message);
@@ -113,6 +136,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
}, },
})); }));
setSelectedDomain(newDomain.id); setSelectedDomain(newDomain.id);
// Auto-expand newly added domain
setExpandedDomains(prev => ({
...prev,
[newDomain.id]: true,
}));
} }
await mutateDomains(); await mutateDomains();
@@ -322,20 +350,42 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
</div> </div>
</div> </div>
{!status.verified && Array.isArray(status.tokens) && (status.tokens as string[]).length > 0 && ( {Array.isArray(status.tokens) && (status.tokens as string[]).length > 0 && (
<div className="mt-4 pt-4 border-t border-neutral-200"> <div className="mt-3">
<Alert> <button
<AlertDescription> onClick={() =>
<div className="space-y-4"> setExpandedDomains(prev => ({
<div> ...prev,
<p className="font-medium text-sm mb-1">DNS Configuration Required</p> [domain.id]: !prev[domain.id],
<p className="text-xs text-neutral-600"> }))
Add the following DNS records to verify your domain. DNS changes can take up to 48 }
hours to propagate. className="flex items-center gap-1.5 text-xs text-neutral-600 hover:text-neutral-900 transition-colors"
>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${
expandedDomains[domain.id] ? 'rotate-180' : ''
}`}
/>
<span>{status.verified ? 'View DNS Records' : 'DNS Configuration Required'}</span>
</button>
{expandedDomains[domain.id] && (
<div className="mt-3 space-y-4">
{/* Required DKIM Records for Sending */}
<div>
<div className="flex items-center gap-2 mb-2">
<h4 className="text-xs font-semibold text-neutral-900">Required for Sending</h4>
<Badge variant="default" className="text-[10px] px-1.5 py-0">
REQUIRED
</Badge>
</div>
{!status.verified && (
<p className="text-xs text-neutral-600 mb-2">
Add these DKIM records to verify your domain and send emails. DNS changes can take up
to 48 hours to propagate.
</p> </p>
</div> )}
{/* DNS Records Table */}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs border-collapse"> <table className="w-full text-xs border-collapse">
<thead> <thead>
@@ -401,120 +451,248 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
</td> </td>
</tr> </tr>
))} ))}
{/* MX Record */}
<tr className="hover:bg-neutral-50/50">
<td className="py-3 px-3">
<code className="text-xs font-medium text-neutral-900">MX</code>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
plunk.{domain.domain}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === `plunk.${domain.domain}-3000` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
10 feedback-smtp.eu-north-1.amazonses.com
</code>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleCopyToken('10 feedback-smtp.eu-north-1.amazonses.com', 1000)
}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === '10 feedback-smtp.eu-north-1.amazonses.com-1000' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
</tr>
{/* TXT Record (SPF) */}
<tr className="hover:bg-neutral-50/50">
<td className="py-3 px-3">
<code className="text-xs font-medium text-neutral-900">TXT</code>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
plunk.{domain.domain}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === `plunk.${domain.domain}-3001` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
&quot;v=spf1 include:amazonses.com ~all&quot;
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001)}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === '"v=spf1 include:amazonses.com ~all"-1001' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex items-start gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200">
<div className="text-blue-600 mt-0.5">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<p className="text-xs text-blue-900">
Click the copy icon to copy record values. After adding all records to your DNS
provider, use the refresh button above to verify your domain.
</p>
</div>
</div> </div>
</AlertDescription>
</Alert> {/* Optional: Custom MAIL FROM Domain */}
{config?.aws?.sesRegion && (
<div>
<div className="flex items-center gap-2 mb-2">
<h4 className="text-xs font-semibold text-neutral-900">
Custom MAIL FROM Domain (Optional)
</h4>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
OPTIONAL
</Badge>
</div>
<p className="text-xs text-neutral-600 mb-2">
Set up a custom MAIL FROM domain (plunk.{domain.domain}) to improve deliverability and
handle bounces/complaints.
</p>
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="border-b border-neutral-200">
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
Type
</th>
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
Name
</th>
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
Value
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200">
{/* MX Record (Bounce/Complaint Handling) */}
<tr className="hover:bg-neutral-50/50">
<td className="py-3 px-3">
<code className="text-xs font-medium text-neutral-900">MX</code>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
plunk.{domain.domain}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === `plunk.${domain.domain}-3000` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
10 feedback-smtp.{config.aws.sesRegion}.amazonses.com
</code>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleCopyToken(
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com`,
1000,
)
}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken ===
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com-1000` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
</tr>
{/* TXT Record (SPF) */}
<tr className="hover:bg-neutral-50/50">
<td className="py-3 px-3">
<code className="text-xs font-medium text-neutral-900">TXT</code>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
plunk.{domain.domain}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === `plunk.${domain.domain}-3001` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
&quot;v=spf1 include:amazonses.com ~all&quot;
</code>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001)
}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === '"v=spf1 include:amazonses.com ~all"-1001' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
{/* Optional: Inbound Email */}
{config?.aws?.sesRegion && (
<div>
<div className="flex items-center gap-2 mb-2">
<h4 className="text-xs font-semibold text-neutral-900">Inbound Email (Optional)</h4>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
OPTIONAL
</Badge>
</div>
<p className="text-xs text-neutral-600 mb-2">
Configure this MX record to receive emails at your domain.
</p>
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="border-b border-neutral-200">
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
Type
</th>
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
Name
</th>
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
Value
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200">
{/* Inbound MX Record */}
<tr className="hover:bg-neutral-50/50">
<td className="py-3 px-3">
<code className="text-xs font-medium text-neutral-900">MX</code>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
{domain.domain}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyToken(domain.domain, 3002)}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken === `${domain.domain}-3002` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
<td className="py-3 px-3">
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
10 inbound-smtp.{config.aws.sesRegion}.amazonaws.com
</code>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleCopyToken(
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com`,
1002,
)
}
className="shrink-0 h-6 w-6 p-0"
>
{copiedToken ===
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com-1002` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
<div className="flex items-start gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200 mt-3">
<div className="text-blue-600 mt-0.5">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<p className="text-xs text-blue-900">
Click the copy icon to copy record values. After adding all records to your DNS
provider, use the refresh button above to verify your domain.
</p>
</div>
</div>
)}
</div> </div>
)} )}
</div> </div>
+3
View File
@@ -19,6 +19,9 @@ export interface ConfigResponse {
ports: {secure: number; submission: number} | null; ports: {secure: number; submission: number} | null;
}; };
}; };
aws: {
sesRegion: string;
};
} }
/** /**