/** * Formatting utilities for email notifications and webhook payloads. */ import type { FormDefinition, Submission, SubmissionFile } from "./types.js"; import { getFormFields } from "./types.js"; const CSV_ESCAPE_RE = /[,"\n]/; const DOUBLE_QUOTE_RE = /"/g; const CSV_FORMULA_TRIGGERS = new Set(["=", "+", "-", "@", "\t", "\r"]); /** * Format a submission as plain text for email notifications. */ export function formatSubmissionText( form: FormDefinition, data: Record, files?: SubmissionFile[], ): string { const fields = getFormFields(form); const lines: string[] = [`New submission for "${form.name}"`, ""]; for (const field of fields) { if (field.type === "hidden") continue; const value = data[field.name]; if (value === undefined || value === null || value === "") continue; const display = Array.isArray(value) ? (value as string[]).join(", ") : String(value as string | number | boolean); lines.push(`${field.label}: ${display}`); } if (files && files.length > 0) { lines.push("", "Attached files:"); for (const file of files) { lines.push(` - ${file.filename} (${formatBytes(file.size)})`); } } lines.push("", `Submitted at: ${new Date().toISOString()}`); return lines.join("\n"); } /** * Format a digest email summarizing submissions over a period. */ export function formatDigestText( form: FormDefinition, formId: string, submissions: Submission[], siteUrl: string, ): string { const lines: string[] = [ `Daily digest for "${form.name}"`, "", `${submissions.length} new submission${submissions.length === 1 ? "" : "s"} since last digest.`, "", ]; for (const sub of submissions.slice(0, 10)) { const preview = getSubmissionPreview(form, sub); lines.push(` - ${sub.createdAt}: ${preview}`); } if (submissions.length > 10) { lines.push(` ... and ${submissions.length - 10} more`); } lines.push( "", `View all submissions: ${siteUrl}/_emdash/admin/plugins/emdash-forms/submissions?formId=${encodeURIComponent(formId)}`, ); return lines.join("\n"); } /** * Format a webhook payload for a new submission. */ export function formatWebhookPayload( form: FormDefinition, submissionId: string, data: Record, files?: SubmissionFile[], ): Record { return { event: "form.submission", formId: form.slug, formName: form.name, submissionId, data, files: files?.map((f) => ({ fieldName: f.fieldName, filename: f.filename, contentType: f.contentType, size: f.size, mediaId: f.mediaId, })), submittedAt: new Date().toISOString(), }; } /** * Format submissions as CSV. */ export function formatCsv( form: FormDefinition, items: Array<{ id: string; data: Submission }>, ): string { const fields = getFormFields(form).filter((f) => f.type !== "hidden"); const headers = ["ID", "Submitted At", "Status", ...fields.map((f) => f.label)]; const rows = items.map(({ id, data: sub }) => { const values = [id, sub.createdAt, sub.status]; for (const field of fields) { const v = sub.data[field.name]; if (field.type === "file") { const file = sub.files?.find((f) => f.fieldName === field.name); values.push(file ? file.filename : ""); } else if (Array.isArray(v)) { values.push(v.join("; ")); } else { values.push(v === undefined || v === null ? "" : String(v as string | number | boolean)); } } return values; }); return [headers, ...rows].map((row) => row.map(escapeCsv).join(",")).join("\n"); } function escapeCsv(value: string): string { // Neutralize formula triggers to prevent CSV injection in spreadsheet apps if (value.length > 0 && CSV_FORMULA_TRIGGERS.has(value.charAt(0))) { value = "'" + value; } if (CSV_ESCAPE_RE.test(value)) { return `"${value.replace(DOUBLE_QUOTE_RE, '""')}"`; } return value; } function getSubmissionPreview(form: FormDefinition, sub: Submission): string { const fields = getFormFields(form).filter((f) => f.type !== "hidden" && f.type !== "file"); const previews: string[] = []; for (const field of fields.slice(0, 3)) { const v = sub.data[field.name]; if (v !== undefined && v !== null && v !== "") { const str = String(v as string | number | boolean); previews.push(str.length > 50 ? `${str.slice(0, 47)}...` : str); } } return previews.join(" | ") || "(empty)"; } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }