feat: Add platform emails for domain verification and expiration

This commit is contained in:
Dries Augustyns
2025-12-31 15:56:40 +01:00
parent 68b7e7c082
commit 19554e6e8f
6 changed files with 356 additions and 2 deletions
+73 -1
View File
@@ -6,8 +6,11 @@
* Scheduled to run every 5 minutes via repeatable jobs * Scheduled to run every 5 minutes via repeatable jobs
*/ */
import React from 'react';
import signale from 'signale'; 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 {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js'; import {redis} from '../database/redis.js';
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.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) // Process domains in batches of 99 (AWS SES limit is 100)
for (let i = 0; i < count; i += 99) { for (let i = 0; i < count; i += 99) {
const domains = await prisma.domain.findMany({ 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, skip: i,
take: 99, take: 99,
}); });
@@ -102,6 +113,34 @@ export async function checkDomainVerifications() {
signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${error}`); 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 // Invalidate cache
await redis.del(Keys.Domain.id(dbDomain.id)); await redis.del(Keys.Domain.id(dbDomain.id));
await redis.del(Keys.Domain.project(dbDomain.projectId)); await redis.del(Keys.Domain.project(dbDomain.projectId));
@@ -111,6 +150,39 @@ export async function checkDomainVerifications() {
if (dbDomain.verified && !isVerified) { if (dbDomain.verified && !isVerified) {
signale.warn(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is no longer verified`); 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.id(dbDomain.id));
await redis.del(Keys.Domain.project(dbDomain.projectId)); await redis.del(Keys.Domain.project(dbDomain.projectId));
} }
+70 -1
View File
@@ -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 {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 {HttpException} from '../exceptions/index.js';
import {Keys} from './keys.js'; import {Keys} from './keys.js';
import {NtfyService} from './NtfyService.js'; import {NtfyService} from './NtfyService.js';
@@ -81,6 +85,36 @@ export class DomainService {
// Send notification about domain verified // Send notification about domain verified
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id); 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) { } else if (attributes.status !== 'Success' && domain.verified) {
const updatedDomain = await prisma.domain.update({ const updatedDomain = await prisma.domain.update({
where: {id: domainId}, where: {id: domainId},
@@ -94,6 +128,41 @@ export class DomainService {
// Send notification about domain verification failed // Send notification about domain verification failed
await NtfyService.notifyDomainVerificationFailed(domain.domain, updatedDomain.project.name, updatedDomain.project.id); 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 { return {
+6
View File
@@ -26,6 +26,12 @@ export const Keys = {
project(projectId: string): string { project(projectId: string): string {
return `domain:project:${projectId}`; 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: { Billing: {
usage(projectId: string, sourceType: string, year: number, month: string): string { usage(projectId: string, sourceType: string, year: number, month: string): string {
@@ -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 (
<EmailLayout>
<Header />
<Section className="px-8 pb-10 pt-10">
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">
Domain verification failed
</Heading>
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
Your domain <strong className="font-medium text-gray-900">{domain}</strong> for project{' '}
<strong className="font-medium text-gray-900">{projectName}</strong> is no longer verified. Email sending from
this domain has been disabled.
</Text>
<Section className="mb-8 rounded-lg bg-red-50 px-6 py-4" style={{border: '1px solid #fca5a5'}}>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-red-900">
Emails cannot be sent from this domain until verification is restored. Please check your DNS records and
re-verify your domain.
</Text>
</Section>
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">Common causes</Heading>
<Section className="mb-8 overflow-hidden rounded-lg" style={{border: '1px solid #e5e7eb'}}>
<Section className="bg-gray-50 px-6 py-4">
<Text className="mb-0 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
Why verification might fail
</Text>
</Section>
<Section className="px-6 py-6">
<Section className="mb-3">
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-700">
DNS records were removed or modified incorrectly
</Text>
</Section>
<Section className="mb-3">
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-700">DNS propagation issues or delays</Text>
</Section>
<Section className="mb-3">
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-700">
Domain ownership or registrar changed
</Text>
</Section>
</Section>
</Section>
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">Steps to fix</Heading>
<Section className="mb-8">
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Check your DNS records</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Verify that all DKIM records are still in place and configured correctly
</Text>
</Section>
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Re-verify your domain</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Trigger a verification check in your domain settings
</Text>
</Section>
<Section>
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Update your templates</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
If needed, switch to a verified domain to continue sending emails
</Text>
</Section>
</Section>
<Section className="mb-6">
<Link
href={`${dashboardUrl}/settings?tab=domains`}
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
>
Fix domain verification
</Link>
</Section>
<Section>
<Link href={dashboardUrl} className="text-sm text-gray-500" style={{textDecoration: 'none'}}>
View project dashboard
</Link>
</Section>
</Section>
<Footer projectId={projectId} landingUrl={landingUrl} />
</EmailLayout>
);
}
export default DomainUnverifiedEmail;
@@ -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 (
<EmailLayout>
<Header />
<Section className="px-8 pb-10 pt-10">
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">
Domain verified successfully
</Heading>
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
Your domain <strong className="font-medium text-gray-900">{domain}</strong> for project{' '}
<strong className="font-medium text-gray-900">{projectName}</strong> has been successfully verified and is now
ready to send emails.
</Text>
<Section className="mb-8 rounded-lg bg-gray-50 px-6 py-4" style={{border: '1px solid #e5e7eb'}}>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-700">
Your domain is now active and can be used to send emails. You can start using it in your templates and
campaigns immediately.
</Text>
</Section>
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">Next steps</Heading>
<Section className="mb-8">
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Start sending emails</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Use this domain in your templates and campaigns
</Text>
</Section>
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Configure email addresses</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Set up sender addresses with this domain for your emails
</Text>
</Section>
<Section>
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Review DNS settings</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Ensure your DNS settings remain in place to maintain verification
</Text>
</Section>
</Section>
<Section className="mb-6">
<Link
href={`${dashboardUrl}/settings?tab=domains`}
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
>
View domain settings
</Link>
</Section>
<Section>
<Link href={dashboardUrl} className="text-sm text-gray-500" style={{textDecoration: 'none'}}>
View project dashboard
</Link>
</Section>
</Section>
<Footer projectId={projectId} landingUrl={landingUrl} />
</EmailLayout>
);
}
export default DomainVerifiedEmail;
+2
View File
@@ -3,3 +3,5 @@ export {BillingLimitWarningEmail} from './BillingLimitWarning';
export {BillingLimitExceededEmail} from './BillingLimitExceeded'; export {BillingLimitExceededEmail} from './BillingLimitExceeded';
export {EmailVerificationEmail} from './EmailVerification'; export {EmailVerificationEmail} from './EmailVerification';
export {PasswordResetEmail} from './PasswordReset'; export {PasswordResetEmail} from './PasswordReset';
export {DomainVerifiedEmail} from './DomainVerified';
export {DomainUnverifiedEmail} from './DomainUnverified';