diff --git a/apps/api/src/jobs/domain-verification.ts b/apps/api/src/jobs/domain-verification.ts index 506a7b4..d718d7d 100644 --- a/apps/api/src/jobs/domain-verification.ts +++ b/apps/api/src/jobs/domain-verification.ts @@ -6,8 +6,11 @@ * Scheduled to run every 5 minutes via repeatable jobs */ +import React from 'react'; import signale from 'signale'; +import {DomainVerifiedEmail, DomainUnverifiedEmail, sendPlatformEmail} from '@plunk/email'; +import {DASHBOARD_URI, LANDING_URI} from '../constants.js'; import {prisma} from '../database/prisma.js'; import {redis} from '../database/redis.js'; import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js'; @@ -26,7 +29,15 @@ export async function checkDomainVerifications() { // Process domains in batches of 99 (AWS SES limit is 100) for (let i = 0; i < count; i += 99) { const domains = await prisma.domain.findMany({ - select: {id: true, domain: true, projectId: true, verified: true}, + select: { + id: true, + domain: true, + projectId: true, + verified: true, + project: { + select: {name: true}, + }, + }, skip: i, take: 99, }); @@ -102,6 +113,34 @@ export async function checkDomainVerifications() { signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${error}`); } + // Send email notification about domain verified + try { + const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id); + const alreadySent = await redis.get(cacheKey); + if (alreadySent !== '1') { + const members = await prisma.membership.findMany({ + where: {projectId: dbDomain.projectId}, + include: {user: {select: {email: true}}}, + }); + const emails = members.map((m) => m.user.email); + if (emails.length > 0) { + const template = React.createElement(DomainVerifiedEmail, { + projectName: dbDomain.project.name, + projectId: dbDomain.projectId, + domain: sesIdentity.domain, + dashboardUrl: DASHBOARD_URI, + landingUrl: LANDING_URI, + }); + await Promise.all( + emails.map((email) => sendPlatformEmail(email, 'Domain Verified Successfully', template)), + ); + await redis.setex(cacheKey, 604800, '1'); // 7 days + } + } + } catch (error) { + signale.error(`[DOMAIN-VERIFICATION] Error sending verified email: ${error}`); + } + // Invalidate cache await redis.del(Keys.Domain.id(dbDomain.id)); await redis.del(Keys.Domain.project(dbDomain.projectId)); @@ -111,6 +150,39 @@ export async function checkDomainVerifications() { if (dbDomain.verified && !isVerified) { signale.warn(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is no longer verified`); + // Send email notification about domain verification failed + try { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month); + const alreadySent = await redis.get(cacheKey); + if (alreadySent !== '1') { + const members = await prisma.membership.findMany({ + where: {projectId: dbDomain.projectId}, + include: {user: {select: {email: true}}}, + }); + const emails = members.map((m) => m.user.email); + if (emails.length > 0) { + const template = React.createElement(DomainUnverifiedEmail, { + projectName: dbDomain.project.name, + projectId: dbDomain.projectId, + domain: sesIdentity.domain, + dashboardUrl: DASHBOARD_URI, + landingUrl: LANDING_URI, + }); + await Promise.all( + emails.map((email) => sendPlatformEmail(email, 'Domain Verification Failed', template)), + ); + const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000); + await redis.setex(cacheKey, ttl, '1'); + } + } + } catch (error) { + signale.error(`[DOMAIN-VERIFICATION] Error sending unverified email: ${error}`); + } + await redis.del(Keys.Domain.id(dbDomain.id)); await redis.del(Keys.Domain.project(dbDomain.projectId)); } diff --git a/apps/api/src/services/DomainService.ts b/apps/api/src/services/DomainService.ts index 84001f2..7d1503c 100644 --- a/apps/api/src/services/DomainService.ts +++ b/apps/api/src/services/DomainService.ts @@ -1,5 +1,9 @@ +import React from 'react'; +import signale from 'signale'; +import {DomainVerifiedEmail, DomainUnverifiedEmail, sendPlatformEmail} from '@plunk/email'; +import {DASHBOARD_URI, LANDING_URI} from '../constants.js'; import {prisma} from '../database/prisma.js'; -import {wrapRedis} from '../database/redis.js'; +import {redis, wrapRedis} from '../database/redis.js'; import {HttpException} from '../exceptions/index.js'; import {Keys} from './keys.js'; import {NtfyService} from './NtfyService.js'; @@ -81,6 +85,36 @@ export class DomainService { // Send notification about domain verified await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id); + + // Send email notification about domain verified + try { + // Check deduplication cache + const cacheKey = Keys.Domain.verifiedEmail(domainId); + const alreadySent = await redis.get(cacheKey); + if (alreadySent !== '1') { + const members = await prisma.membership.findMany({ + where: {projectId: updatedDomain.project.id}, + include: {user: {select: {email: true}}}, + }); + const emails = members.map((m) => m.user.email); + if (emails.length > 0) { + const template = React.createElement(DomainVerifiedEmail, { + projectName: updatedDomain.project.name, + projectId: updatedDomain.project.id, + domain: domain.domain, + dashboardUrl: DASHBOARD_URI, + landingUrl: LANDING_URI, + }); + await Promise.all( + emails.map((email) => sendPlatformEmail(email, 'Domain Verified Successfully', template)), + ); + // Set cache to prevent duplicate emails (7 days) + await redis.setex(cacheKey, 604800, '1'); + } + } + } catch (emailError) { + signale.error('[DOMAIN-EMAIL] Failed to send domain verified email:', emailError); + } } else if (attributes.status !== 'Success' && domain.verified) { const updatedDomain = await prisma.domain.update({ where: {id: domainId}, @@ -94,6 +128,41 @@ export class DomainService { // Send notification about domain verification failed await NtfyService.notifyDomainVerificationFailed(domain.domain, updatedDomain.project.name, updatedDomain.project.id); + + // Send email notification about domain verification failed + try { + // Check deduplication cache (monthly) + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month); + const alreadySent = await redis.get(cacheKey); + if (alreadySent !== '1') { + const members = await prisma.membership.findMany({ + where: {projectId: updatedDomain.project.id}, + include: {user: {select: {email: true}}}, + }); + const emails = members.map((m) => m.user.email); + if (emails.length > 0) { + const template = React.createElement(DomainUnverifiedEmail, { + projectName: updatedDomain.project.name, + projectId: updatedDomain.project.id, + domain: domain.domain, + dashboardUrl: DASHBOARD_URI, + landingUrl: LANDING_URI, + }); + await Promise.all( + emails.map((email) => sendPlatformEmail(email, 'Domain Verification Failed', template)), + ); + // Set cache to prevent duplicate emails (until end of month) + const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); + const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000); + await redis.setex(cacheKey, ttl, '1'); + } + } + } catch (emailError) { + signale.error('[DOMAIN-EMAIL] Failed to send domain unverified email:', emailError); + } } return { diff --git a/apps/api/src/services/keys.ts b/apps/api/src/services/keys.ts index 6f6a98f..b4ac80a 100644 --- a/apps/api/src/services/keys.ts +++ b/apps/api/src/services/keys.ts @@ -26,6 +26,12 @@ export const Keys = { project(projectId: string): string { return `domain:project:${projectId}`; }, + verifiedEmail(domainId: string): string { + return `domain:verified_email:${domainId}`; + }, + unverifiedEmail(domainId: string, year: number, month: string): string { + return `domain:unverified_email:${domainId}:${year}-${month}`; + }, }, Billing: { usage(projectId: string, sourceType: string, year: number, month: string): string { diff --git a/packages/email/src/emails/DomainUnverified.tsx b/packages/email/src/emails/DomainUnverified.tsx new file mode 100644 index 0000000..802568c --- /dev/null +++ b/packages/email/src/emails/DomainUnverified.tsx @@ -0,0 +1,115 @@ +import {Heading, Link, Section, Text} from '@react-email/components'; +import * as React from 'react'; +import {EmailLayout} from '../common/EmailLayout'; +import {Footer} from '../common/Footer'; +import {Header} from '../common/Header'; + +interface DomainUnverifiedEmailProps { + projectName: string; + projectId: string; + domain: string; + dashboardUrl?: string; + landingUrl?: string; +} + +export function DomainUnverifiedEmail({ + projectName = 'My Project', + projectId = 'proj_example123', + domain = 'example.com', + dashboardUrl = 'https://next-app.useplunk.com', + landingUrl = 'https://next.useplunk.com', +}: DomainUnverifiedEmailProps) { + return ( + +
+ +
+ + Domain verification failed + + + + Your domain {domain} for project{' '} + {projectName} is no longer verified. Email sending from + this domain has been disabled. + + +
+ + Emails cannot be sent from this domain until verification is restored. Please check your DNS records and + re-verify your domain. + +
+ + Common causes + +
+
+ + Why verification might fail + +
+
+
+ + DNS records were removed or modified incorrectly + +
+
+ DNS propagation issues or delays +
+
+ + Domain ownership or registrar changed + +
+
+
+ + Steps to fix + +
+
+ Check your DNS records + + Verify that all DKIM records are still in place and configured correctly + +
+ +
+ Re-verify your domain + + Trigger a verification check in your domain settings + +
+ +
+ Update your templates + + If needed, switch to a verified domain to continue sending emails + +
+
+ +
+ + Fix domain verification + +
+ +
+ + View project dashboard → + +
+
+ +