Files
plunk/apps/api/src/controllers/Actions.ts
T

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,
});
}
}