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 →
+
+
+
+
+
+
+ );
+}
+
+export default DomainUnverifiedEmail;
diff --git a/packages/email/src/emails/DomainVerified.tsx b/packages/email/src/emails/DomainVerified.tsx
new file mode 100644
index 0000000..b240e9d
--- /dev/null
+++ b/packages/email/src/emails/DomainVerified.tsx
@@ -0,0 +1,90 @@
+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 DomainVerifiedEmailProps {
+ projectName: string;
+ projectId: string;
+ domain: string;
+ dashboardUrl?: string;
+ landingUrl?: string;
+}
+
+export function DomainVerifiedEmail({
+ projectName = 'My Project',
+ projectId = 'proj_example123',
+ domain = 'example.com',
+ dashboardUrl = 'https://next-app.useplunk.com',
+ landingUrl = 'https://next.useplunk.com',
+}: DomainVerifiedEmailProps) {
+ return (
+
+
+
+
+
+ Domain verified successfully
+
+
+
+ Your domain {domain} for project{' '}
+ {projectName} has been successfully verified and is now
+ ready to send emails.
+
+
+
+
+ Your domain is now active and can be used to send emails. You can start using it in your templates and
+ campaigns immediately.
+
+
+
+ Next steps
+
+
+
+ Start sending emails
+
+ Use this domain in your templates and campaigns
+
+
+
+
+ Configure email addresses
+
+ Set up sender addresses with this domain for your emails
+
+
+
+
+ Review DNS settings
+
+ Ensure your DNS settings remain in place to maintain verification
+
+
+
+
+
+
+ View domain settings
+
+
+
+
+
+ View project dashboard →
+
+
+
+
+
+
+ );
+}
+
+export default DomainVerifiedEmail;
diff --git a/packages/email/src/emails/index.ts b/packages/email/src/emails/index.ts
index 34d41e5..89b5e5e 100644
--- a/packages/email/src/emails/index.ts
+++ b/packages/email/src/emails/index.ts
@@ -3,3 +3,5 @@ export {BillingLimitWarningEmail} from './BillingLimitWarning';
export {BillingLimitExceededEmail} from './BillingLimitExceeded';
export {EmailVerificationEmail} from './EmailVerification';
export {PasswordResetEmail} from './PasswordReset';
+export {DomainVerifiedEmail} from './DomainVerified';
+export {DomainUnverifiedEmail} from './DomainUnverified';