398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
import {Controller, Middleware, Post} from '@overnightjs/core';
|
|
import {ActionSchemas} from '@plunk/shared';
|
|
import type {NextFunction, Request, Response} from 'express';
|
|
import {requirePublicKey, requireSecretKey} from '../middleware/auth.js';
|
|
import {prisma} from '../database/prisma.js';
|
|
import {ContactService} from '../services/ContactService.js';
|
|
import {DomainService} from '../services/DomainService.js';
|
|
import {EmailService} from '../services/EmailService.js';
|
|
import {EmailVerificationService} from '../services/EmailVerificationService.js';
|
|
import {EventService} from '../services/EventService.js';
|
|
import {NotFound, ValidationError} from '../exceptions/index.js';
|
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
|
import {DASHBOARD_URI} from '../app/constants.js';
|
|
|
|
/**
|
|
* Public API Actions Controller
|
|
* Handles track event, transactional email, and email verification endpoints
|
|
*/
|
|
@Controller('v1')
|
|
export class Actions {
|
|
/**
|
|
* POST /v1/track
|
|
* Track an event for a contact (creates/updates contact and tracks event)
|
|
*
|
|
* Request body:
|
|
* - event: string (required) - Event name
|
|
* - email: string (required) - Contact email
|
|
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
|
|
* - data: object (optional) - Event and contact data
|
|
* - Simple values are saved to contact (persistent)
|
|
* - {value: any, persistent: false} are only available to workflows (non-persistent)
|
|
*
|
|
* Response:
|
|
* - success: boolean
|
|
* - data: object with contact ID, event ID, and timestamp
|
|
*
|
|
* Example:
|
|
* {
|
|
* event: "purchase",
|
|
* email: "user@example.com",
|
|
* data: {
|
|
* totalSpent: 1500, // Persistent - saved to contact
|
|
* plan: "pro", // Persistent - saved to contact
|
|
* orderId: {value: "12345", persistent: false}, // Non-persistent - workflows only
|
|
* receiptUrl: {value: "https://...", persistent: false} // Non-persistent - workflows only
|
|
* }
|
|
* }
|
|
*/
|
|
@Post('track')
|
|
@Middleware([requirePublicKey])
|
|
@CatchAsync
|
|
public async track(req: Request, res: Response, _next: NextFunction) {
|
|
const auth = res.locals.auth;
|
|
|
|
// Zod validation - errors automatically handled by global error handler
|
|
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
|
|
|
|
// Prevent manual tracking of reserved system events
|
|
if (EventService.isReservedEvent(event)) {
|
|
throw new ValidationError(
|
|
[
|
|
{
|
|
field: 'event',
|
|
message: `Event name "${event}" is reserved for system use and cannot be manually tracked`,
|
|
code: 'reserved_event',
|
|
received: event,
|
|
},
|
|
],
|
|
'Cannot track reserved system event',
|
|
);
|
|
}
|
|
|
|
// Create or update contact with persistent data only
|
|
// ContactService.upsert will filter out non-persistent fields
|
|
const contact = await ContactService.upsert(
|
|
auth.projectId,
|
|
email,
|
|
data as Record<string, unknown> | undefined,
|
|
subscribed,
|
|
);
|
|
|
|
// Track the event with ALL data (persistent + non-persistent)
|
|
// Non-persistent data flows to workflows via execution context
|
|
const eventRecord = await EventService.trackEvent(
|
|
auth.projectId,
|
|
event,
|
|
contact.id,
|
|
undefined,
|
|
data as Record<string, unknown> | undefined,
|
|
);
|
|
|
|
return res.status(200).json({
|
|
success: true,
|
|
data: {
|
|
contact: contact.id,
|
|
event: eventRecord.id,
|
|
timestamp: eventRecord.createdAt.toISOString(),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* POST /v1/send
|
|
* Send transactional email(s)
|
|
*
|
|
* Request body:
|
|
* - to: string | object | array (required) - Recipient email(s)
|
|
* - String: "user@example.com"
|
|
* - Object: {name: "Jane Doe", email: "user@example.com"}
|
|
* - Array: ["user1@example.com", {name: "Jane", email: "user2@example.com"}]
|
|
* - subject: string (required) - Email subject
|
|
* - body: string (required) - Email HTML body
|
|
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
|
|
* - name: string (optional) - Sender name (alternative to from.name)
|
|
* - from: string | object (optional) - Sender email or {name, email} object (must be from verified domain)
|
|
* - reply: string (optional) - Reply-to email
|
|
* - headers: object (optional) - Additional email headers
|
|
* - data: object (optional) - Contact data and template variables
|
|
* - Simple values are saved to contact (persistent)
|
|
* - {value: any, persistent: false} are only used for this email (non-persistent)
|
|
* - attachments: array (optional) - Email attachments (max 10, 10MB total)
|
|
* - filename: string (required) - Attachment filename
|
|
* - content: string (required) - Base64 encoded file content
|
|
* - contentType: string (required) - MIME type (e.g., "application/pdf")
|
|
*
|
|
* Response:
|
|
* - success: boolean
|
|
* - data: object with emails array and timestamp
|
|
*
|
|
* Examples:
|
|
*
|
|
* Simple format (backward compatible):
|
|
* {
|
|
* to: "user@example.com",
|
|
* subject: "Password Reset",
|
|
* body: "<p>Reset code: {{resetCode}}</p><p>Hello {{firstName}}!</p>",
|
|
* from: "noreply@example.com",
|
|
* name: "My App",
|
|
* data: {
|
|
* firstName: "John", // Persistent - saved to contact
|
|
* resetCode: {value: "ABC123", persistent: false} // Non-persistent - this email only
|
|
* }
|
|
* }
|
|
*
|
|
* Object format (recommended):
|
|
* {
|
|
* to: {
|
|
* name: "Jane Doe",
|
|
* email: "user@example.com"
|
|
* },
|
|
* subject: "Password Reset",
|
|
* body: "<p>Reset code: {{resetCode}}</p>",
|
|
* from: {
|
|
* name: "My App",
|
|
* email: "noreply@example.com"
|
|
* }
|
|
* }
|
|
*
|
|
* Multiple recipients with names:
|
|
* {
|
|
* to: [
|
|
* {name: "Jane Doe", email: "jane@example.com"},
|
|
* {name: "John Smith", email: "john@example.com"}
|
|
* ],
|
|
* subject: "Newsletter",
|
|
* body: "<p>Hello {{name}}!</p>",
|
|
* from: {name: "Newsletter", email: "news@example.com"}
|
|
* }
|
|
*/
|
|
@Post('send')
|
|
@Middleware([requireSecretKey])
|
|
@CatchAsync
|
|
public async send(req: Request, res: Response, _next: NextFunction) {
|
|
const auth = res.locals.auth;
|
|
|
|
// Zod validation - errors automatically handled by global error handler
|
|
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
|
|
ActionSchemas.send.parse(req.body);
|
|
|
|
// Normalize recipients to array and parse email/name
|
|
type Recipient = {email: string; name?: string};
|
|
const recipients: Recipient[] = (Array.isArray(to) ? to : [to]).map(recipient => {
|
|
if (typeof recipient === 'string') {
|
|
return {email: recipient};
|
|
} else {
|
|
return {email: recipient.email, name: recipient.name};
|
|
}
|
|
});
|
|
|
|
// Parse 'from' field - can be string or object {name, email}
|
|
let emailFrom: string | undefined;
|
|
let emailFromName: string | undefined;
|
|
|
|
if (typeof from === 'string') {
|
|
// Backward compatible: from is just an email string
|
|
emailFrom = from;
|
|
emailFromName = name; // Use separate 'name' field if provided
|
|
} else if (from && typeof from === 'object') {
|
|
// New format: from is an object with {name, email}
|
|
emailFrom = from.email;
|
|
emailFromName = from.name || name; // Prefer from.name, fallback to separate 'name' field
|
|
} else {
|
|
// No 'from' provided
|
|
emailFromName = name;
|
|
}
|
|
|
|
// Fetch template if provided
|
|
let emailSubject = subject;
|
|
let emailBody = body;
|
|
let emailReplyTo = reply;
|
|
let templateId: string | undefined;
|
|
|
|
if (template) {
|
|
const templateRecord = await prisma.template.findUnique({
|
|
where: {
|
|
id: template,
|
|
projectId: auth.projectId, // Ensure template belongs to this project
|
|
},
|
|
});
|
|
|
|
if (!templateRecord) {
|
|
throw new NotFound('Template', template);
|
|
}
|
|
|
|
// Use template values, allow overrides from request
|
|
emailSubject = subject || templateRecord.subject;
|
|
emailBody = body || templateRecord.body;
|
|
|
|
// Handle from field - if not already set and template has a from, use it
|
|
if (!emailFrom && templateRecord.from) {
|
|
emailFrom = templateRecord.from;
|
|
}
|
|
if (!emailFromName && templateRecord.fromName) {
|
|
emailFromName = templateRecord.fromName;
|
|
}
|
|
|
|
emailReplyTo = reply || templateRecord.replyTo || undefined;
|
|
templateId = templateRecord.id;
|
|
}
|
|
|
|
if (!emailFrom) {
|
|
throw new ValidationError(
|
|
[
|
|
{
|
|
field: 'from',
|
|
message: 'Sender email is required either in request or template',
|
|
code: 'required',
|
|
},
|
|
],
|
|
'Could not parse sender email',
|
|
);
|
|
}
|
|
|
|
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
|
|
|
|
const replyToEmail = emailReplyTo;
|
|
|
|
const timestamp = new Date();
|
|
const emailResults = [];
|
|
|
|
// Process each recipient
|
|
for (const recipient of recipients) {
|
|
// Merge recipient name with data if provided
|
|
const recipientData = recipient.name
|
|
? {...(data as Record<string, unknown> | undefined), name: recipient.name}
|
|
: (data as Record<string, unknown> | undefined);
|
|
|
|
// Create or update contact with metadata
|
|
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed);
|
|
|
|
// Get merged data including non-persistent fields for template rendering
|
|
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
|
|
|
// Add system variables (email, unsubscribe URLs, etc.) to merged data
|
|
// These are always available for template rendering
|
|
const dataWithSystemVars = {
|
|
...mergedData,
|
|
email: contact.email,
|
|
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
|
|
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
|
|
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
|
|
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
|
|
};
|
|
|
|
// Render template with contact data
|
|
// Simple template variable replacement: {{fieldname}}
|
|
let renderedSubject = emailSubject!;
|
|
let renderedBody = emailBody!;
|
|
|
|
for (const [key, value] of Object.entries(dataWithSystemVars)) {
|
|
const placeholder = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
|
|
const fallbackPlaceholder = new RegExp(`\\{\\{\\s*${key}\\s*\\?\\?\\s*([^}]+)\\}\\}`, 'g');
|
|
|
|
// Replace with value
|
|
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
|
renderedSubject = renderedSubject!.replace(placeholder, stringValue);
|
|
renderedBody = renderedBody!.replace(placeholder, stringValue);
|
|
|
|
// Handle fallback syntax: {{field ?? default}}
|
|
renderedSubject = renderedSubject!.replace(fallbackPlaceholder, stringValue || '$1');
|
|
renderedBody = renderedBody!.replace(fallbackPlaceholder, stringValue || '$1');
|
|
}
|
|
|
|
// Replace any remaining placeholders with empty string or fallback value
|
|
renderedSubject = renderedSubject!.replace(/\{\{\s*(\w+)\s*\}\}/g, '');
|
|
renderedBody = renderedBody!.replace(/\{\{\s*(\w+)\s*\}\}/g, '');
|
|
|
|
// Handle fallback placeholders that weren't matched
|
|
renderedSubject = renderedSubject!.replace(/\{\{\s*\w+\s*\?\?\s*([^}]+)\}\}/g, '$1');
|
|
renderedBody = renderedBody!.replace(/\{\{\s*\w+\s*\?\?\s*([^}]+)\}\}/g, '$1');
|
|
|
|
const email = await EmailService.sendTransactionalEmail({
|
|
projectId: auth.projectId,
|
|
contactId: contact.id,
|
|
subject: renderedSubject,
|
|
body: renderedBody,
|
|
from: emailFrom,
|
|
fromName: emailFromName,
|
|
toName: recipient.name,
|
|
replyTo: replyToEmail,
|
|
headers: headers || undefined,
|
|
attachments: attachments || undefined,
|
|
templateId: templateId,
|
|
});
|
|
|
|
emailResults.push({
|
|
contact: {
|
|
id: contact.id,
|
|
email: contact.email,
|
|
},
|
|
email: email.id,
|
|
});
|
|
}
|
|
|
|
return res.status(200).json({
|
|
success: true,
|
|
data: {
|
|
emails: emailResults,
|
|
timestamp: timestamp.toISOString(),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* POST /v1/verify
|
|
* Verify an email address
|
|
*
|
|
* Request body:
|
|
* - email: string (required) - Email address to verify
|
|
*
|
|
* Response:
|
|
* - success: boolean
|
|
* - data: object with verification results
|
|
* - email: string - Email address that was verified
|
|
* - valid: boolean - Whether the email appears to be valid
|
|
* - isDisposable: boolean - Whether the email is from a disposable domain
|
|
* - hasMxRecords: boolean - Whether the domain has MX records configured
|
|
* - suggestedEmail?: string - Suggested correction if typo detected
|
|
* - reasons: string[] - Array of reasons describing the verification results
|
|
*
|
|
* Example:
|
|
* {
|
|
* email: "user@gmial.com"
|
|
* }
|
|
*
|
|
* Response:
|
|
* {
|
|
* success: true,
|
|
* data: {
|
|
* email: "user@gmial.com",
|
|
* valid: false,
|
|
* isDisposable: false,
|
|
* hasMxRecords: false,
|
|
* suggestedEmail: "user@gmail.com",
|
|
* reasons: [
|
|
* "Possible typo detected, did you mean user@gmail.com?",
|
|
* "Domain does not exist or has no MX records"
|
|
* ]
|
|
* }
|
|
* }
|
|
*/
|
|
@Post('verify')
|
|
@Middleware([requireSecretKey])
|
|
@CatchAsync
|
|
public async verify(req: Request, res: Response, _next: NextFunction) {
|
|
// Zod validation - errors automatically handled by global error handler
|
|
const {email} = ActionSchemas.verify.parse(req.body);
|
|
|
|
// Verify the email address
|
|
const verificationResult = await EmailVerificationService.verifyEmail(email);
|
|
|
|
return res.status(200).json({
|
|
success: true,
|
|
data: verificationResult,
|
|
});
|
|
}
|
|
}
|