diff --git a/apps/api/src/controllers/Config.ts b/apps/api/src/controllers/Config.ts index a121a9c..89d458c 100644 --- a/apps/api/src/controllers/Config.ts +++ b/apps/api/src/controllers/Config.ts @@ -3,6 +3,7 @@ import type {Request, Response} from 'express'; import { API_URI, + AWS_SES_REGION, DASHBOARD_URI, GITHUB_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED, @@ -59,6 +60,9 @@ export class Config { : null, }, }, + aws: { + sesRegion: AWS_SES_REGION, + }, }); } } diff --git a/apps/api/src/controllers/Webhooks.ts b/apps/api/src/controllers/Webhooks.ts index a4134ac..a6ce39b 100644 --- a/apps/api/src/controllers/Webhooks.ts +++ b/apps/api/src/controllers/Webhooks.ts @@ -8,6 +8,7 @@ import type Stripe from 'stripe'; import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js'; import {stripe} from '../app/stripe.js'; import {prisma} from '../database/prisma.js'; +import {ContactService} from '../services/ContactService.js'; import {EventService} from '../services/EventService.js'; import {NtfyService} from '../services/NtfyService.js'; import {SecurityService} from '../services/SecurityService.js'; @@ -72,25 +73,107 @@ export class Webhooks { // Check if this is an inbound email notification (SES Receiving) if (body.notificationType === 'Received') { signale.info('[WEBHOOK] Received inbound email notification from SES'); - signale.info('[WEBHOOK] Inbound email details:', { - messageId: body.mail?.messageId, - source: body.mail?.source, - destination: body.mail?.destination, - recipients: body.receipt?.recipients, - timestamp: body.mail?.timestamp, - subject: body.mail?.commonHeaders?.subject, - from: body.mail?.commonHeaders?.from, - to: body.mail?.commonHeaders?.to, - hasContent: !!body.content, - 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, - }); - // For now, just log and acknowledge receipt - // Future: process the inbound email (parse, store, trigger workflows, etc.) - return res.status(200).json({success: true, message: 'Inbound email received'}); + + try { + // Extract recipient addresses from the inbound email + const recipients = body.receipt?.recipients || []; + + if (recipients.length === 0) { + signale.warn('[WEBHOOK] No recipients found in inbound email'); + return res.status(200).json({success: true, message: 'No recipients found'}); + } + + // For each recipient, identify the domain and create events + for (const recipient of recipients) { + const recipientEmail = recipient as string; + const domain = recipientEmail.split('@')[1]; + + if (!domain) { + signale.warn('[WEBHOOK] Invalid recipient email format:', recipientEmail); + continue; + } + + // 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) diff --git a/apps/web/src/components/DomainsSettings.tsx b/apps/web/src/components/DomainsSettings.tsx index f24fe42..f0b67a3 100644 --- a/apps/web/src/components/DomainsSettings.tsx +++ b/apps/web/src/components/DomainsSettings.tsx @@ -3,8 +3,6 @@ import {useForm} from 'react-hook-form'; import {zodResolver} from '@hookform/resolvers/zod'; import {DomainSchemas} from '@plunk/shared'; import { - Alert, - AlertDescription, Badge, Button, Card, @@ -22,7 +20,8 @@ import { Input, } from '@plunk/ui'; 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'; interface DomainsSettingsProps { @@ -34,6 +33,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { const {addDomain} = useAddDomain(); const {checkVerification} = useCheckDomainVerification(); const {removeDomain} = useRemoveDomain(); + const {data: config} = useConfig(); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); @@ -47,6 +47,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { const [cooldownSeconds, setCooldownSeconds] = useState<{[key: string]: number}>({}); const [showRemoveDialog, setShowRemoveDialog] = useState(false); const [domainToRemove, setDomainToRemove] = useState<{id: string; name: string} | null>(null); + const [expandedDomains, setExpandedDomains] = useState<{[key: string]: boolean}>({}); const form = useForm<{domain: string}>({ resolver: zodResolver(DomainSchemas.create.omit({projectId: true})), @@ -86,6 +87,28 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { return () => clearInterval(interval); }, [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) => { if (type === 'success') { setSuccessMessage(message); @@ -113,6 +136,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { }, })); setSelectedDomain(newDomain.id); + // Auto-expand newly added domain + setExpandedDomains(prev => ({ + ...prev, + [newDomain.id]: true, + })); } await mutateDomains(); @@ -322,20 +350,42 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { - {!status.verified && Array.isArray(status.tokens) && (status.tokens as string[]).length > 0 && ( -
- - -
-
-

DNS Configuration Required

-

- Add the following DNS records to verify your domain. DNS changes can take up to 48 - hours to propagate. + {Array.isArray(status.tokens) && (status.tokens as string[]).length > 0 && ( +

+ + + {expandedDomains[domain.id] && ( +
+ {/* Required DKIM Records for Sending */} +
+
+

Required for Sending

+ + REQUIRED + +
+ {!status.verified && ( +

+ Add these DKIM records to verify your domain and send emails. DNS changes can take up + to 48 hours to propagate.

-
+ )} - {/* DNS Records Table */}
@@ -401,120 +451,248 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) { ))} - - {/* MX Record */} - - - - - - - {/* TXT Record (SPF) */} - - - - -
- MX - -
- - plunk.{domain.domain} - - -
-
-
- - 10 feedback-smtp.eu-north-1.amazonses.com - - -
-
- TXT - -
- - plunk.{domain.domain} - - -
-
-
- - "v=spf1 include:amazonses.com ~all" - - -
-
- -
-
- - - -
-

- 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. -

-
- - + + {/* Optional: Custom MAIL FROM Domain */} + {config?.aws?.sesRegion && ( +
+
+

+ Custom MAIL FROM Domain (Optional) +

+ + OPTIONAL + +
+

+ Set up a custom MAIL FROM domain (plunk.{domain.domain}) to improve deliverability and + handle bounces/complaints. +

+ +
+ + + + + + + + + + {/* MX Record (Bounce/Complaint Handling) */} + + + + + + + {/* TXT Record (SPF) */} + + + + + + +
+ Type + + Name + + Value +
+ MX + +
+ + plunk.{domain.domain} + + +
+
+
+ + 10 feedback-smtp.{config.aws.sesRegion}.amazonses.com + + +
+
+ TXT + +
+ + plunk.{domain.domain} + + +
+
+
+ + "v=spf1 include:amazonses.com ~all" + + +
+
+
+
+ )} + + {/* Optional: Inbound Email */} + {config?.aws?.sesRegion && ( +
+
+

Inbound Email (Optional)

+ + OPTIONAL + +
+

+ Configure this MX record to receive emails at your domain. +

+ +
+ + + + + + + + + + {/* Inbound MX Record */} + + + + + + +
+ Type + + Name + + Value +
+ MX + +
+ + {domain.domain} + + +
+
+
+ + 10 inbound-smtp.{config.aws.sesRegion}.amazonaws.com + + +
+
+
+
+ )} + +
+
+ + + +
+

+ 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. +

+
+
+ )}
)}
diff --git a/apps/web/src/lib/hooks/useConfig.ts b/apps/web/src/lib/hooks/useConfig.ts index 3a08246..c4198df 100644 --- a/apps/web/src/lib/hooks/useConfig.ts +++ b/apps/web/src/lib/hooks/useConfig.ts @@ -19,6 +19,9 @@ export interface ConfigResponse { ports: {secure: number; submission: number} | null; }; }; + aws: { + sesRegion: string; + }; } /**