1134 lines
34 KiB
TypeScript
1134 lines
34 KiB
TypeScript
import type {Contact, Email, Prisma, Project} from '@plunk/db';
|
|
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
|
|
import {toPrismaJson} from '@plunk/types';
|
|
import signale from 'signale';
|
|
|
|
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
|
import {prisma} from '../database/prisma.js';
|
|
import {HttpException} from '../exceptions/index.js';
|
|
import {createTranslatorSync, renderTemplate} from '@plunk/shared';
|
|
|
|
import {BillingLimitService} from './BillingLimitService.js';
|
|
import {DomainService} from './DomainService.js';
|
|
import {EventService} from './EventService.js';
|
|
import {QueueService} from './QueueService.js';
|
|
import {sendRawEmail} from './SESService.js';
|
|
|
|
interface Attachment {
|
|
filename: string;
|
|
content: string; // Base64 encoded
|
|
contentType: string;
|
|
contentId?: string;
|
|
disposition?: 'attachment' | 'inline';
|
|
}
|
|
|
|
interface SendEmailParams {
|
|
projectId: string;
|
|
contactId: string;
|
|
subject: string;
|
|
body: string;
|
|
from: string;
|
|
fromName?: string;
|
|
toName?: string;
|
|
replyTo?: string;
|
|
headers?: Record<string, string>;
|
|
attachments?: Attachment[];
|
|
templateId?: string;
|
|
campaignId?: string;
|
|
workflowExecutionId?: string;
|
|
workflowStepExecutionId?: string;
|
|
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
|
|
}
|
|
|
|
/**
|
|
* Email Service
|
|
* Handles sending emails and tracking delivery
|
|
*/
|
|
export class EmailService {
|
|
/**
|
|
* Send a transactional email via API
|
|
*/
|
|
public static async sendTransactionalEmail(params: SendEmailParams): Promise<Email> {
|
|
// Check if a template is used and if it's a marketing template
|
|
// Marketing templates should not be sent to unsubscribed contacts even via the transactional API
|
|
if (params.templateId) {
|
|
const template = await prisma.template.findUnique({
|
|
where: {id: params.templateId},
|
|
select: {type: true},
|
|
});
|
|
|
|
// If using a marketing template, check subscription status
|
|
if (template?.type === 'MARKETING') {
|
|
const contact = await prisma.contact.findUnique({
|
|
where: {id: params.contactId},
|
|
select: {subscribed: true, email: true},
|
|
});
|
|
|
|
if (!contact?.subscribed) {
|
|
throw new HttpException(
|
|
400,
|
|
`Cannot send marketing template to unsubscribed contact ${contact?.email || params.contactId}. Use a transactional template or send without a template.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check billing limit before sending
|
|
const limitCheck = await BillingLimitService.checkLimit(params.projectId, EmailSourceType.TRANSACTIONAL);
|
|
|
|
if (!limitCheck.allowed) {
|
|
throw new HttpException(429, limitCheck.message || 'Billing limit exceeded for transactional emails');
|
|
}
|
|
|
|
// Log warning if approaching limit (80%)
|
|
if (limitCheck.warning) {
|
|
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
|
|
}
|
|
|
|
const email = await prisma.email.create({
|
|
data: {
|
|
projectId: params.projectId,
|
|
contactId: params.contactId,
|
|
subject: params.subject,
|
|
body: params.body,
|
|
from: params.from,
|
|
fromName: params.fromName,
|
|
toName: params.toName,
|
|
replyTo: params.replyTo,
|
|
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
|
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
|
templateId: params.templateId,
|
|
status: EmailStatus.PENDING,
|
|
},
|
|
});
|
|
|
|
// Increment usage counter in cache
|
|
await BillingLimitService.incrementUsage(params.projectId, EmailSourceType.TRANSACTIONAL);
|
|
|
|
// Queue email for sending
|
|
await this.queueEmail(email.id);
|
|
|
|
return email;
|
|
}
|
|
|
|
/**
|
|
* Send a campaign email
|
|
*/
|
|
public static async sendCampaignEmail(params: SendEmailParams): Promise<Email> {
|
|
// Check if template is transactional to determine source type
|
|
let sourceType: EmailSourceType = EmailSourceType.CAMPAIGN;
|
|
|
|
if (params.templateId) {
|
|
const template = await prisma.template.findUnique({
|
|
where: {id: params.templateId},
|
|
select: {type: true},
|
|
});
|
|
|
|
// If template is marked as TRANSACTIONAL, use TRANSACTIONAL sourceType
|
|
// This ensures unsubscribe footer is not added to transactional emails
|
|
if (template?.type === 'TRANSACTIONAL') {
|
|
sourceType = EmailSourceType.TRANSACTIONAL;
|
|
}
|
|
}
|
|
|
|
// Check billing limit before sending
|
|
const limitCheck = await BillingLimitService.checkLimit(params.projectId, sourceType);
|
|
|
|
if (!limitCheck.allowed) {
|
|
throw new HttpException(
|
|
429,
|
|
limitCheck.message || `Billing limit exceeded for ${sourceType.toLowerCase()} emails`,
|
|
);
|
|
}
|
|
|
|
// Log warning if approaching limit (80%)
|
|
if (limitCheck.warning) {
|
|
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
|
|
}
|
|
|
|
const email = await prisma.email.create({
|
|
data: {
|
|
projectId: params.projectId,
|
|
contactId: params.contactId,
|
|
subject: params.subject,
|
|
body: params.body,
|
|
from: params.from,
|
|
fromName: params.fromName,
|
|
replyTo: params.replyTo,
|
|
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
|
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
|
sourceType,
|
|
templateId: params.templateId,
|
|
campaignId: params.campaignId,
|
|
status: EmailStatus.PENDING,
|
|
},
|
|
});
|
|
|
|
// Increment usage counter in cache
|
|
await BillingLimitService.incrementUsage(params.projectId, sourceType);
|
|
|
|
// Queue email for sending
|
|
await this.queueEmail(email.id);
|
|
|
|
return email;
|
|
}
|
|
|
|
/**
|
|
* Send a workflow email
|
|
*/
|
|
public static async sendWorkflowEmail(params: SendEmailParams): Promise<Email> {
|
|
// Check if template is transactional to determine source type
|
|
let sourceType: EmailSourceType = EmailSourceType.WORKFLOW;
|
|
|
|
if (params.templateId) {
|
|
const template = await prisma.template.findUnique({
|
|
where: {id: params.templateId},
|
|
select: {type: true},
|
|
});
|
|
|
|
// If template is marked as TRANSACTIONAL, use TRANSACTIONAL sourceType
|
|
// This ensures unsubscribe footer is not added to transactional emails
|
|
if (template?.type === 'TRANSACTIONAL') {
|
|
sourceType = EmailSourceType.TRANSACTIONAL;
|
|
}
|
|
}
|
|
|
|
// Check subscription status for marketing emails
|
|
// Transactional emails should always be sent regardless of subscription status
|
|
// Custom recipient emails also bypass subscription checks (they're not in the contact list)
|
|
if (sourceType !== EmailSourceType.TRANSACTIONAL && !params.recipientEmail) {
|
|
const contact = await prisma.contact.findUnique({
|
|
where: {id: params.contactId},
|
|
select: {subscribed: true},
|
|
});
|
|
|
|
if (!contact?.subscribed) {
|
|
signale.info(
|
|
`[WORKFLOW] Skipping marketing email to unsubscribed contact ${params.contactId} in workflow execution ${params.workflowExecutionId}`,
|
|
);
|
|
// For workflows, we silently skip sending to unsubscribed contacts for marketing emails
|
|
// Return a placeholder email record that won't be sent
|
|
return await prisma.email.create({
|
|
data: {
|
|
projectId: params.projectId,
|
|
contactId: params.contactId,
|
|
subject: params.subject,
|
|
body: params.body,
|
|
from: params.from,
|
|
fromName: params.fromName,
|
|
replyTo: params.replyTo,
|
|
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
|
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
|
sourceType,
|
|
templateId: params.templateId,
|
|
workflowExecutionId: params.workflowExecutionId,
|
|
workflowStepExecutionId: params.workflowStepExecutionId,
|
|
status: EmailStatus.FAILED,
|
|
error: 'Contact is unsubscribed from marketing emails',
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check billing limit before sending
|
|
const limitCheck = await BillingLimitService.checkLimit(params.projectId, sourceType);
|
|
|
|
if (!limitCheck.allowed) {
|
|
throw new HttpException(
|
|
429,
|
|
limitCheck.message || `Billing limit exceeded for ${sourceType.toLowerCase()} emails`,
|
|
);
|
|
}
|
|
|
|
// Log warning if approaching limit (80%)
|
|
if (limitCheck.warning) {
|
|
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
|
|
}
|
|
|
|
// If custom recipient email is provided, store it in headers for later use
|
|
const emailHeaders = params.headers ? {...params.headers} : {};
|
|
if (params.recipientEmail) {
|
|
emailHeaders['X-Plunk-Recipient-Override'] = params.recipientEmail;
|
|
}
|
|
|
|
const email = await prisma.email.create({
|
|
data: {
|
|
projectId: params.projectId,
|
|
contactId: params.contactId,
|
|
subject: params.subject,
|
|
body: params.body,
|
|
from: params.from,
|
|
fromName: params.fromName,
|
|
replyTo: params.replyTo,
|
|
headers: Object.keys(emailHeaders).length > 0 ? toPrismaJson(emailHeaders) : undefined,
|
|
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
|
sourceType,
|
|
templateId: params.templateId,
|
|
workflowExecutionId: params.workflowExecutionId,
|
|
workflowStepExecutionId: params.workflowStepExecutionId,
|
|
status: EmailStatus.PENDING,
|
|
},
|
|
});
|
|
|
|
// Increment usage counter in cache
|
|
await BillingLimitService.incrementUsage(params.projectId, sourceType);
|
|
|
|
// Queue email for sending
|
|
await this.queueEmail(email.id);
|
|
|
|
return email;
|
|
}
|
|
|
|
/**
|
|
* Actually send the email via AWS SES
|
|
* This is called by the email processor worker
|
|
*/
|
|
public static async sendEmail(emailId: string): Promise<void> {
|
|
const email = await prisma.email.findUnique({
|
|
where: {id: emailId},
|
|
include: {
|
|
contact: true,
|
|
project: true,
|
|
template: {
|
|
select: {type: true},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!email) {
|
|
throw new HttpException(404, 'Email not found');
|
|
}
|
|
|
|
if (email.status !== EmailStatus.PENDING) {
|
|
return; // Already processed
|
|
}
|
|
|
|
// Final validation: Check subscription status before sending
|
|
// Only transactional emails should be sent to unsubscribed contacts
|
|
if (!email.contact.subscribed) {
|
|
const isTransactional =
|
|
email.sourceType === EmailSourceType.TRANSACTIONAL || email.template?.type === 'TRANSACTIONAL';
|
|
|
|
if (!isTransactional) {
|
|
signale.warn(`[EMAIL] Skipping marketing email ${emailId} to unsubscribed contact ${email.contact.email}`);
|
|
await prisma.email.update({
|
|
where: {id: emailId},
|
|
data: {
|
|
status: EmailStatus.FAILED,
|
|
error: 'Contact is unsubscribed from marketing emails',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Verify domain is registered and verified before sending
|
|
// This ensures all emails (transactional, campaign, workflow) use verified domains
|
|
await DomainService.verifyEmailDomain(email.from, email.projectId);
|
|
|
|
// Update status to sending
|
|
await prisma.email.update({
|
|
where: {id: emailId},
|
|
data: {status: EmailStatus.SENDING},
|
|
});
|
|
|
|
// Format template variables in subject and body
|
|
const contactData =
|
|
email.contact.data && typeof email.contact.data === 'object' && !Array.isArray(email.contact.data)
|
|
? email.contact.data
|
|
: {};
|
|
const formattedEmail = this.format({
|
|
subject: email.subject,
|
|
body: email.body,
|
|
data: {
|
|
id: email.contact.id,
|
|
email: email.contact.email,
|
|
...contactData,
|
|
data: contactData,
|
|
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${email.contact.id}`,
|
|
subscribeUrl: `${DASHBOARD_URI}/subscribe/${email.contact.id}`,
|
|
manageUrl: `${DASHBOARD_URI}/manage/${email.contact.id}`,
|
|
},
|
|
});
|
|
|
|
// Compile HTML with unsubscribe footer and badge
|
|
const compiledHtml = this.compile({
|
|
content: formattedEmail.body,
|
|
contact: email.contact,
|
|
project: email.project,
|
|
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
|
|
});
|
|
|
|
// Use explicit fromName if provided, otherwise fall back to project name
|
|
const fromName = email.fromName || email.project.name;
|
|
const fromEmail = email.from;
|
|
|
|
// Parse custom headers from JSON
|
|
const customHeaders =
|
|
email.headers && typeof email.headers === 'object' && !Array.isArray(email.headers)
|
|
? (email.headers as Record<string, string>)
|
|
: undefined;
|
|
|
|
// Check for custom recipient override in headers
|
|
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
|
|
|
|
// Remove internal headers before sending
|
|
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
|
|
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
|
|
delete publicHeaders['X-Plunk-Recipient-Override'];
|
|
}
|
|
|
|
// Parse attachments from JSON
|
|
const attachments =
|
|
email.attachments && Array.isArray(email.attachments)
|
|
? (email.attachments as Array<{
|
|
filename: string;
|
|
content: string;
|
|
contentType: string;
|
|
contentId?: string;
|
|
disposition?: 'attachment' | 'inline';
|
|
}>)
|
|
: undefined;
|
|
|
|
// Determine tracking based on project settings and email type
|
|
const shouldTrack = this.shouldTrackEmail(email.project.tracking, email.sourceType);
|
|
|
|
// Send via AWS SES
|
|
const result = await sendRawEmail({
|
|
from: {
|
|
name: fromName,
|
|
email: fromEmail,
|
|
},
|
|
to: [recipientEmail],
|
|
content: {
|
|
subject: formattedEmail.subject,
|
|
html: compiledHtml,
|
|
},
|
|
reply: email.replyTo || undefined,
|
|
headers: publicHeaders,
|
|
attachments: attachments,
|
|
tracking: shouldTrack,
|
|
});
|
|
|
|
// Mark as sent with SES message ID
|
|
await prisma.email.update({
|
|
where: {id: emailId},
|
|
data: {
|
|
status: EmailStatus.SENT,
|
|
sentAt: new Date(),
|
|
messageId: result.messageId,
|
|
},
|
|
});
|
|
|
|
// Track event (this will trigger workflows)
|
|
await EventService.trackEvent(email.projectId, 'email.sent', email.contactId, email.id, {
|
|
subject: formattedEmail.subject,
|
|
from: email.from,
|
|
fromName: email.fromName,
|
|
messageId: result.messageId,
|
|
templateId: email.templateId,
|
|
campaignId: email.campaignId,
|
|
sourceType: email.sourceType,
|
|
sentAt: new Date().toISOString(),
|
|
});
|
|
} catch (error) {
|
|
signale.error(`[EMAIL] Failed to send email ${emailId}:`, error);
|
|
|
|
// Mark as failed
|
|
await prisma.email.update({
|
|
where: {id: emailId},
|
|
data: {
|
|
status: EmailStatus.FAILED,
|
|
error: error instanceof Error ? error.message : 'Unknown error',
|
|
},
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process email webhook events (opens, clicks, bounces, etc.)
|
|
* This would be called by webhook endpoints from your email provider
|
|
*/
|
|
public static async handleWebhookEvent(
|
|
emailId: string,
|
|
eventType: 'opened' | 'clicked' | 'bounced' | 'complained' | 'delivered',
|
|
metadata?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const email = await prisma.email.findUnique({
|
|
where: {id: emailId},
|
|
});
|
|
|
|
if (!email) {
|
|
throw new HttpException(404, 'Email not found');
|
|
}
|
|
|
|
const now = new Date();
|
|
const updateData: Prisma.EmailUpdateInput = {};
|
|
|
|
switch (eventType) {
|
|
case 'delivered':
|
|
updateData.status = EmailStatus.DELIVERED;
|
|
updateData.deliveredAt = now;
|
|
break;
|
|
|
|
case 'opened':
|
|
if (!email.openedAt) {
|
|
updateData.openedAt = now;
|
|
}
|
|
updateData.opens = (email.opens || 0) + 1;
|
|
updateData.status = EmailStatus.OPENED;
|
|
break;
|
|
|
|
case 'clicked':
|
|
if (!email.clickedAt) {
|
|
updateData.clickedAt = now;
|
|
}
|
|
updateData.clicks = (email.clicks || 0) + 1;
|
|
updateData.status = EmailStatus.CLICKED;
|
|
break;
|
|
|
|
case 'bounced':
|
|
updateData.status = EmailStatus.BOUNCED;
|
|
updateData.bouncedAt = now;
|
|
// Unsubscribe contact on bounce and track event
|
|
if (email.contactId) {
|
|
await prisma.contact.update({
|
|
where: {id: email.contactId},
|
|
data: {subscribed: false},
|
|
});
|
|
// Track unsubscription event
|
|
await EventService.trackEvent(email.projectId, 'contact.unsubscribed', email.contactId, email.id, {
|
|
reason: 'bounce',
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'complained':
|
|
updateData.status = EmailStatus.COMPLAINED;
|
|
updateData.complainedAt = now;
|
|
// Unsubscribe contact and track event
|
|
if (email.contactId) {
|
|
await prisma.contact.update({
|
|
where: {id: email.contactId},
|
|
data: {subscribed: false},
|
|
});
|
|
// Track unsubscription event
|
|
await EventService.trackEvent(email.projectId, 'contact.unsubscribed', email.contactId, email.id, {
|
|
reason: 'complaint',
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
await prisma.email.update({
|
|
where: {id: emailId},
|
|
data: updateData,
|
|
});
|
|
|
|
// Update campaign stats if applicable
|
|
if (email.campaignId) {
|
|
const campaignUpdate: Prisma.CampaignUpdateInput = {};
|
|
|
|
switch (eventType) {
|
|
case 'delivered':
|
|
campaignUpdate.deliveredCount = {increment: 1};
|
|
break;
|
|
|
|
case 'opened':
|
|
// Only increment unique opens to match getStats logic
|
|
if (!email.openedAt) {
|
|
campaignUpdate.openedCount = {increment: 1};
|
|
}
|
|
break;
|
|
|
|
case 'clicked':
|
|
// Only increment unique clicks to match getStats logic
|
|
if (!email.clickedAt) {
|
|
campaignUpdate.clickedCount = {increment: 1};
|
|
}
|
|
break;
|
|
|
|
case 'bounced':
|
|
campaignUpdate.bouncedCount = {increment: 1};
|
|
break;
|
|
}
|
|
|
|
if (Object.keys(campaignUpdate).length > 0) {
|
|
await prisma.campaign.update({
|
|
where: {id: email.campaignId},
|
|
data: campaignUpdate,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Track event
|
|
await prisma.event.create({
|
|
data: {
|
|
projectId: email.projectId,
|
|
contactId: email.contactId,
|
|
emailId: email.id,
|
|
name: `email.${eventType}`,
|
|
data: metadata ? toPrismaJson(metadata) : undefined,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get email statistics for a project
|
|
*/
|
|
public static async getStats(projectId: string, startDate?: Date, endDate?: Date) {
|
|
const where: Prisma.EmailWhereInput = {
|
|
projectId,
|
|
...(startDate || endDate
|
|
? {
|
|
createdAt: {
|
|
...(startDate ? {gte: startDate} : {}),
|
|
...(endDate ? {lte: endDate} : {}),
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
const [total, sent, delivered, received, opened, clicked, bounced, failed] = await Promise.all([
|
|
prisma.email.count({where}),
|
|
prisma.email.count({where: {...where, status: EmailStatus.SENT}}),
|
|
prisma.email.count({where: {...where, status: EmailStatus.DELIVERED}}),
|
|
prisma.email.count({where: {...where, status: EmailStatus.RECEIVED}}),
|
|
prisma.email.count({where: {...where, status: EmailStatus.OPENED}}),
|
|
prisma.email.count({where: {...where, status: EmailStatus.CLICKED}}),
|
|
prisma.email.count({where: {...where, status: EmailStatus.BOUNCED}}),
|
|
prisma.email.count({where: {...where, status: EmailStatus.FAILED}}),
|
|
]);
|
|
|
|
return {
|
|
total,
|
|
sent,
|
|
delivered,
|
|
received,
|
|
opened,
|
|
clicked,
|
|
bounced,
|
|
failed,
|
|
openRate: sent > 0 ? (opened / sent) * 100 : 0,
|
|
clickRate: sent > 0 ? (clicked / sent) * 100 : 0,
|
|
bounceRate: sent > 0 ? (bounced / sent) * 100 : 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Format email template by replacing variables in subject and body
|
|
* Uses shared template rendering from @plunk/shared
|
|
*/
|
|
public static format({subject, body, data}: {subject: string; body: string; data: Record<string, unknown>}): {
|
|
subject: string;
|
|
body: string;
|
|
} {
|
|
return {
|
|
subject: renderTemplate(subject, data),
|
|
body: renderTemplate(body, data),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Determine if an email should be tracked based on project tracking mode and email source type
|
|
*/
|
|
public static shouldTrackEmail(trackingMode: TrackingMode, sourceType: EmailSourceType): boolean {
|
|
switch (trackingMode) {
|
|
case TrackingMode.ENABLED:
|
|
return true;
|
|
case TrackingMode.DISABLED:
|
|
return false;
|
|
case TrackingMode.MARKETING_ONLY:
|
|
// Track only campaigns and workflows (marketing), not transactional emails
|
|
return sourceType !== EmailSourceType.TRANSACTIONAL;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
|
* rather than the visual editor. Mirrors the same logic in apps/web/src/lib/emailStyles.ts.
|
|
*/
|
|
private static detectCustomHtmlPatterns(html: string): boolean {
|
|
if (!html || html.trim() === '') return false;
|
|
|
|
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
|
|
|
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
|
let hasCustomClasses = false;
|
|
for (const match of classMatches) {
|
|
const classValue = match[1];
|
|
if (!classValue) continue;
|
|
const classes = classValue.split(/\s+/).filter((c: string) => c.length > 0);
|
|
const allowedPrefixes = ['prose', 'variable-', 'email-image', 'ProseMirror', 'resizable-image', 'selected', 'resize-handle'];
|
|
const hasDisallowedClass = classes.some((cls: string) => !allowedPrefixes.some((prefix: string) => cls.startsWith(prefix)));
|
|
if (hasDisallowedClass) {
|
|
hasCustomClasses = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
|
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
|
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
|
const hasMediaQueries = /@media/i.test(html);
|
|
const hasStyleTags = /<style[^>]*>/i.test(html);
|
|
|
|
return (
|
|
hasInlineStyles ||
|
|
hasCustomClasses ||
|
|
hasCustomAttributes ||
|
|
hasComplexTables ||
|
|
hasCustomElements ||
|
|
hasMediaQueries ||
|
|
hasStyleTags
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Wraps visual editor content with a full HTML document and prose styles.
|
|
* Mirrors wrapEmailWithStyles() in apps/web/src/lib/emailStyles.ts so sent emails
|
|
* match the preview modal exactly.
|
|
*/
|
|
private static wrapWithEmailStyles(htmlBody: string): string {
|
|
return `<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<style>
|
|
/* Base reset */
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
padding: 16px;
|
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
line-height: 1.5;
|
|
color: #111827;
|
|
}
|
|
|
|
/* Tailwind Typography (prose) base styles */
|
|
.prose {
|
|
color: #374151;
|
|
max-width: 600px;
|
|
}
|
|
.prose [class~="lead"] {
|
|
color: #4b5563;
|
|
font-size: 1.25em;
|
|
line-height: 1.6;
|
|
margin-top: 1.2em;
|
|
margin-bottom: 1.2em;
|
|
}
|
|
.prose a {
|
|
color: #3b82f6;
|
|
text-decoration: underline;
|
|
font-weight: 500;
|
|
}
|
|
.prose strong {
|
|
color: #111827;
|
|
font-weight: 600;
|
|
}
|
|
.prose ol, .prose ul {
|
|
margin-top: 1.25em;
|
|
margin-bottom: 1.25em;
|
|
padding-left: 1.625em;
|
|
}
|
|
.prose li {
|
|
margin-top: 0.5em;
|
|
margin-bottom: 0.5em;
|
|
}
|
|
.prose ol > li {
|
|
padding-left: 0.375em;
|
|
}
|
|
.prose ul > li {
|
|
padding-left: 0.375em;
|
|
}
|
|
.prose > ul > li p {
|
|
margin-top: 0.75em;
|
|
margin-bottom: 0.75em;
|
|
}
|
|
.prose > ul > li > *:first-child {
|
|
margin-top: 1.25em;
|
|
}
|
|
.prose > ul > li > *:last-child {
|
|
margin-bottom: 1.25em;
|
|
}
|
|
.prose > ol > li > *:first-child {
|
|
margin-top: 1.25em;
|
|
}
|
|
.prose > ol > li > *:last-child {
|
|
margin-bottom: 1.25em;
|
|
}
|
|
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
|
|
margin-top: 0.75em;
|
|
margin-bottom: 0.75em;
|
|
}
|
|
.prose hr {
|
|
border: none;
|
|
border-top: 1px solid #e5e7eb;
|
|
margin-top: 3em;
|
|
margin-bottom: 3em;
|
|
}
|
|
.prose blockquote {
|
|
font-weight: 500;
|
|
font-style: italic;
|
|
color: #111827;
|
|
border-left-width: 0.25rem;
|
|
border-left-color: #e5e7eb;
|
|
quotes: "\\201C""\\201D""\\2018""\\2019";
|
|
margin-top: 1.6em;
|
|
margin-bottom: 1.6em;
|
|
padding-left: 1em;
|
|
}
|
|
.prose h1 {
|
|
color: #111827;
|
|
font-weight: 800;
|
|
font-size: 2.25em;
|
|
margin-top: 0;
|
|
margin-bottom: 0.8888889em;
|
|
line-height: 1.1111111;
|
|
}
|
|
.prose h2 {
|
|
color: #111827;
|
|
font-weight: 700;
|
|
font-size: 1.5em;
|
|
margin-top: 2em;
|
|
margin-bottom: 1em;
|
|
line-height: 1.3333333;
|
|
}
|
|
.prose h3 {
|
|
color: #111827;
|
|
font-weight: 600;
|
|
font-size: 1.25em;
|
|
margin-top: 1.6em;
|
|
margin-bottom: 0.6em;
|
|
line-height: 1.6;
|
|
}
|
|
.prose h4 {
|
|
color: #111827;
|
|
font-weight: 600;
|
|
margin-top: 1.5em;
|
|
margin-bottom: 0.5em;
|
|
line-height: 1.5;
|
|
}
|
|
.prose img {
|
|
margin-top: 2em;
|
|
margin-bottom: 2em;
|
|
}
|
|
.prose figure {
|
|
margin-top: 2em;
|
|
margin-bottom: 2em;
|
|
}
|
|
.prose figure > * {
|
|
margin-top: 0;
|
|
margin-bottom: 0;
|
|
}
|
|
.prose code {
|
|
color: #111827;
|
|
font-weight: 600;
|
|
font-size: 0.875em;
|
|
}
|
|
.prose code::before {
|
|
content: "\`";
|
|
}
|
|
.prose code::after {
|
|
content: "\`";
|
|
}
|
|
.prose pre {
|
|
color: #e5e7eb;
|
|
background-color: #1f2937;
|
|
overflow-x: auto;
|
|
font-size: 0.875em;
|
|
line-height: 1.7142857;
|
|
margin-top: 1.7142857em;
|
|
margin-bottom: 1.7142857em;
|
|
border-radius: 0.375rem;
|
|
padding-top: 0.8571429em;
|
|
padding-right: 1.1428571em;
|
|
padding-bottom: 0.8571429em;
|
|
padding-left: 1.1428571em;
|
|
}
|
|
.prose pre code {
|
|
background-color: transparent;
|
|
border-width: 0;
|
|
border-radius: 0;
|
|
padding: 0;
|
|
font-weight: 400;
|
|
color: inherit;
|
|
font-size: inherit;
|
|
font-family: inherit;
|
|
line-height: inherit;
|
|
}
|
|
.prose pre code::before {
|
|
content: none;
|
|
}
|
|
.prose pre code::after {
|
|
content: none;
|
|
}
|
|
.prose table {
|
|
width: 100%;
|
|
table-layout: auto;
|
|
text-align: left;
|
|
margin-top: 2em;
|
|
margin-bottom: 2em;
|
|
font-size: 0.875em;
|
|
line-height: 1.7142857;
|
|
border-collapse: collapse;
|
|
}
|
|
.prose thead {
|
|
border-bottom-width: 1px;
|
|
border-bottom-color: #d1d5db;
|
|
}
|
|
.prose thead th {
|
|
color: #111827;
|
|
font-weight: 600;
|
|
vertical-align: bottom;
|
|
padding-right: 0.5714286em;
|
|
padding-bottom: 0.5714286em;
|
|
padding-left: 0.5714286em;
|
|
}
|
|
.prose tbody tr {
|
|
border-bottom-width: 1px;
|
|
border-bottom-color: #e5e7eb;
|
|
}
|
|
.prose tbody tr:last-child {
|
|
border-bottom-width: 0;
|
|
}
|
|
.prose tbody td {
|
|
vertical-align: top;
|
|
padding-top: 0.5714286em;
|
|
padding-right: 0.5714286em;
|
|
padding-bottom: 0.5714286em;
|
|
padding-left: 0.5714286em;
|
|
}
|
|
.prose p {
|
|
margin-top: 1.25em;
|
|
margin-bottom: 1.25em;
|
|
}
|
|
|
|
/* prose-sm modifier */
|
|
.prose-sm {
|
|
font-size: 0.875rem;
|
|
line-height: 1.7142857;
|
|
}
|
|
.prose-sm p {
|
|
margin-top: 1.1428571em;
|
|
margin-bottom: 1.1428571em;
|
|
}
|
|
.prose-sm h1 {
|
|
font-size: 2.1428571em;
|
|
margin-top: 0;
|
|
margin-bottom: 0.8em;
|
|
line-height: 1.2;
|
|
}
|
|
.prose-sm h2 {
|
|
font-size: 1.4285714em;
|
|
margin-top: 1.6em;
|
|
margin-bottom: 0.8em;
|
|
line-height: 1.4;
|
|
}
|
|
.prose-sm h3 {
|
|
font-size: 1.2857143em;
|
|
margin-top: 1.5555556em;
|
|
margin-bottom: 0.4444444em;
|
|
line-height: 1.5555556;
|
|
}
|
|
.prose-sm h4 {
|
|
margin-top: 1.4285714em;
|
|
margin-bottom: 0.5714286em;
|
|
line-height: 1.4285714;
|
|
}
|
|
.prose-sm img {
|
|
margin-top: 1.7142857em;
|
|
margin-bottom: 1.7142857em;
|
|
}
|
|
.prose-sm ol, .prose-sm ul {
|
|
margin-top: 1.1428571em;
|
|
margin-bottom: 1.1428571em;
|
|
padding-left: 1.5714286em;
|
|
}
|
|
.prose-sm li {
|
|
margin-top: 0.2857143em;
|
|
margin-bottom: 0.2857143em;
|
|
}
|
|
|
|
/* max-w-none utility */
|
|
.max-w-none {
|
|
max-width: none;
|
|
}
|
|
|
|
/* Custom editor styles */
|
|
.variable-highlight, .variable-placeholder, .variable-mention {
|
|
background-color: #dbeafe;
|
|
color: #1e40af;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 14px;
|
|
display: inline;
|
|
}
|
|
|
|
.prose table {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
margin: 16px 0;
|
|
}
|
|
|
|
.prose th, .prose td {
|
|
border: 1px solid #e5e7eb;
|
|
padding: 8px 12px;
|
|
text-align: left;
|
|
min-width: 100px;
|
|
}
|
|
|
|
.prose th {
|
|
background-color: #f3f4f6;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.prose img {
|
|
max-width: 100%;
|
|
height: auto;
|
|
display: block;
|
|
margin: 16px 0;
|
|
}
|
|
|
|
.prose .resizable-image-wrapper {
|
|
display: block;
|
|
margin: 16px 0;
|
|
}
|
|
|
|
.prose .resizable-image-container {
|
|
display: inline-block;
|
|
position: relative;
|
|
max-width: 100%;
|
|
}
|
|
|
|
.prose .resizable-image-container img {
|
|
margin: 0;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="prose prose-sm max-w-none">
|
|
${htmlBody}
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
/**
|
|
* Compile HTML email with optional unsubscribe footer and badge
|
|
* Adds unsubscribe link and Plunk badge for free tier users (only when billing is enabled)
|
|
*/
|
|
public static compile({
|
|
content,
|
|
contact,
|
|
project,
|
|
includeUnsubscribe = true,
|
|
}: {
|
|
content: string;
|
|
contact: Contact;
|
|
project: Project;
|
|
includeUnsubscribe?: boolean;
|
|
}): string {
|
|
// Wrap visual editor content with prose styles so the sent email matches the preview modal.
|
|
// Custom HTML (from the HTML editor) already carries its own styles and is used as-is.
|
|
let html = this.detectCustomHtmlPatterns(content) ? content : this.wrapWithEmailStyles(content);
|
|
|
|
const unsubscribeHtml = includeUnsubscribe
|
|
? (() => {
|
|
// Get contact-level locale (overrides project language)
|
|
const contactLocale =
|
|
contact.data &&
|
|
typeof contact.data === 'object' &&
|
|
!Array.isArray(contact.data) &&
|
|
'locale' in contact.data &&
|
|
typeof contact.data.locale === 'string'
|
|
? contact.data.locale
|
|
: null;
|
|
|
|
// Get translator for contact's locale or project's language
|
|
const translator = createTranslatorSync(contactLocale || project.language || 'en');
|
|
const unsubscribeText = translator.t('email.footer.unsubscribeText', {
|
|
projectName: project.name,
|
|
});
|
|
const updatePreferencesText = translator.t('email.footer.updatePreferences');
|
|
|
|
return `<table align="center" width="100%" style="max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; font-family: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; border: 0; cellpadding: 0; cellspacing: 0;" role="presentation">
|
|
<tbody>
|
|
<tr>
|
|
<td>
|
|
<hr style="border: none; border-top: 1px solid #eaeaea; width: 100%; margin-top: 12px; margin-bottom: 12px;">
|
|
<p style="font-size: 12px; line-height: 24px; margin: 16px 0; text-align: center; color: rgb(64, 64, 64);">
|
|
${unsubscribeText}
|
|
<a href="${DASHBOARD_URI}/unsubscribe/${contact.id}">${updatePreferencesText}</a>.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>`;
|
|
})()
|
|
: '';
|
|
|
|
// Add Plunk badge if billing is enabled and project has no subscription (free tier)
|
|
const badgeHtml =
|
|
STRIPE_ENABLED && project.subscription === null
|
|
? `<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
|
<tbody>
|
|
<tr>
|
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
|
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
|
<tbody>
|
|
<tr>
|
|
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
|
|
<tbody>
|
|
<tr>
|
|
<td style="width:180px;">
|
|
<a href="${LANDING_URI}?ref=badge" target="_blank">
|
|
<img height="auto" src="https://cdn.useplunk.com/badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180" />
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>`
|
|
: '';
|
|
|
|
// Combine footer and badge
|
|
const footerHtml = `${unsubscribeHtml}${badgeHtml}`;
|
|
|
|
// Insert before closing body tag if it exists, otherwise append
|
|
if (html.includes('</body>')) {
|
|
html = html.replace('</body>', `${footerHtml}</body>`);
|
|
} else {
|
|
html = `${html}${footerHtml}`;
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Queue an email for sending
|
|
* Adds email to the BullMQ queue for processing by workers
|
|
*/
|
|
private static async queueEmail(emailId: string, delay?: number): Promise<void> {
|
|
await QueueService.queueEmail(emailId, delay);
|
|
}
|
|
}
|