/** * Sends Documenso-related signing emails: * * - `sendSigningInvitation` — initial "your turn to sign" email * (one signer at a time). Used both for the first client * invitation after generation AND for the cascading "your turn" * emails when an upstream signer completes. * * - `sendSigningReminder` — follow-up nudge for an unsigned signer. * Rate-limited at the call site (existing * `sendReminderIfAllowed`); this just dispatches the email. * * - `sendSigningCompleted` — sent to all signers (with the signed * PDF attached) when the document reaches fully-signed. * * The service handles two transformations the templates can't: * 1. **Embedded URL wrapping** — raw Documenso signing URLs get * rewrapped to `{embeddedSigningHost}/sign//` so * clients sign on a branded page rather than Documenso's domain. * 2. **Per-port branding lookup** — fetches the port's branding * config (logo, primary color, header/footer HTML) and threads * it into the email shell. * * URL transformation matches the legacy client portal's * `createEmbeddedSigningUrl` (extract token from path, prepend * configured host + signer-role segment). Falls back to the raw * Documenso URL when no `embeddedSigningHost` is configured for the * port (single-tenant deploys can keep using Documenso's hosted UI). */ import pLimit from 'p-limit'; import { sendEmail } from '@/lib/email'; import { getBrandingShell } from '@/lib/email/branding-resolver'; import { signingCompletedEmail, signingInvitationEmail, signingReminderEmail, } from '@/lib/email/templates/document-signing'; import { getPortDocumensoConfig } from '@/lib/services/port-config'; import { logger } from '@/lib/logger'; // ─── Types ─────────────────────────────────────────────────────────────────── export type DocumentLabel = 'Expression of Interest' | 'Sales Contract' | 'Reservation Agreement'; export type SignerRole = 'client' | 'developer' | 'approver' | 'witness' | 'other'; export interface SigningInvitationArgs { portId: string; portName: string; /** Recipient who's being asked to sign right now. */ recipient: { name: string; email: string }; /** Documenso's raw signing URL (e.g. https://signatures.portnimara.dev/sign/). */ documensoSigningUrl: string; /** Document type — drives subject line and body copy. */ documentLabel: DocumentLabel; /** Signer role — drives copy variant + the embedded URL's role segment. */ signerRole: SignerRole; /** Optional rep-authored note inserted above the CTA. */ customMessage?: string | null; /** Display name for the closing salutation (defaults to "The {portName} team"). */ senderName?: string | null; /** Subject override with template tokens. */ subjectOverride?: string | null; } export interface SigningReminderArgs extends Omit { signerRole: SignerRole; /** Human-readable invitation age, e.g. "3 days ago". */ invitedAgo: string; } export interface SigningCompletedArgs { portId: string; portName: string; /** All signers — each gets the same email + attached signed PDF. */ recipients: Array<{ name: string; email: string }>; /** Display name of the linked client (the deal's primary subject). */ clientName: string; documentLabel: DocumentLabel; /** Date all parties had signed. */ completedAt: Date; /** * MinIO file ref for the fully-signed PDF (already stored by the * webhook handler before this service is called). The send pipeline * resolves the ref and attaches the bytes via the existing * `resolveAttachments` flow, which also enforces port-isolation. */ signedPdfFileId: string; signedPdfFilename: string; } // ─── URL transformation ────────────────────────────────────────────────────── /** * Wrap a raw Documenso signing URL into our branded embedded format * `{host}/sign//`. Returns the raw URL unchanged when * the port has no `embeddedSigningHost` configured (single-tenant / * staging deploys skip the wrap). * * Example: * transformSigningUrl( * 'https://signatures.portnimara.dev/sign/abc123', * 'https://portnimara.com', * 'client', * ) → 'https://portnimara.com/sign/client/abc123' */ /** * Map our internal SignerRole to the URL segment expected by the * marketing-website signing page (`/sign//`). The * legacy website only routes `client | cc | developer`; approver + * witness + other all funnel through the `cc` page (which renders the * same Documenso embed but with passive-recipient copy). See plan * Risk #5 — fixing this mapping prevents an `approver` invite from * landing on `/sign/error`. */ const ROLE_TO_URL_SEGMENT: Record = { client: 'client', developer: 'developer', approver: 'cc', witness: 'witness', other: 'cc', }; export function transformSigningUrl( documensoUrl: string, embeddedSigningHost: string | null, signerRole: SignerRole, ): string { if (!embeddedSigningHost || !documensoUrl) return documensoUrl; const token = documensoUrl.split('/').filter(Boolean).pop(); if (!token) return documensoUrl; // Trim trailing slashes off the host so we always produce a clean // single `/` between segments. const host = embeddedSigningHost.replace(/\/+$/, ''); const urlRole = ROLE_TO_URL_SEGMENT[signerRole]; return `${host}/sign/${urlRole}/${token}`; } // ─── Senders ───────────────────────────────────────────────────────────────── export async function sendSigningInvitation(args: SigningInvitationArgs): Promise { const [docCfg, branding] = await Promise.all([ getPortDocumensoConfig(args.portId), getBrandingShell(args.portId), ]); const signingUrl = transformSigningUrl( args.documensoSigningUrl, docCfg.embeddedSigningHost, args.signerRole, ); const { subject, html, text } = await signingInvitationEmail( { recipientName: args.recipient.name, documentLabel: args.documentLabel, signerRole: args.signerRole, signingUrl, portName: args.portName, senderName: args.senderName ?? null, customMessage: args.customMessage ?? null, }, { subject: args.subjectOverride ?? null, branding, }, ); try { await sendEmail(args.recipient.email, subject, html, undefined, text, args.portId); logger.info( { portId: args.portId, recipient: args.recipient.email, documentLabel: args.documentLabel }, 'Signing invitation sent', ); } catch (err) { logger.error( { err, portId: args.portId, recipient: args.recipient.email }, 'Signing invitation send failed', ); throw err; } } export async function sendSigningReminder(args: SigningReminderArgs): Promise { const [docCfg, branding] = await Promise.all([ getPortDocumensoConfig(args.portId), getBrandingShell(args.portId), ]); const signingUrl = transformSigningUrl( args.documensoSigningUrl, docCfg.embeddedSigningHost, args.signerRole, ); const { subject, html, text } = await signingReminderEmail( { recipientName: args.recipient.name, documentLabel: args.documentLabel, signingUrl, portName: args.portName, invitedAgo: args.invitedAgo, customMessage: args.customMessage ?? null, }, { subject: args.subjectOverride ?? null, branding, }, ); try { await sendEmail(args.recipient.email, subject, html, undefined, text, args.portId); logger.info( { portId: args.portId, recipient: args.recipient.email, documentLabel: args.documentLabel }, 'Signing reminder sent', ); } catch (err) { logger.error( { err, portId: args.portId, recipient: args.recipient.email }, 'Signing reminder send failed', ); throw err; } } /** * Send the "all signed" completion email with the finalized PDF * attached. Sends one email per recipient (rather than a single * to-list) so the EMAIL_REDIRECT_TO redirect stays cleanly per-message * and so per-recipient personalization in the body works. */ export async function sendSigningCompleted(args: SigningCompletedArgs): Promise { const branding = await getBrandingShell(args.portId); // Cap concurrency at 3: a Sales Contract with 10 recipients (client + // 5 sellers + 4 witnesses) shouldn't fan out 10 simultaneous SMTP // sends. Most SMTP providers (Mailgun, SES, Postmark) cap concurrent // connections in the single digits and silently drop the overflow. const sendLimit = pLimit(3); await Promise.all( args.recipients.map((recipient) => sendLimit(async () => { const { subject, html, text } = await signingCompletedEmail( { recipientName: recipient.name, documentLabel: args.documentLabel, clientName: args.clientName, portName: args.portName, completedAt: args.completedAt, }, { branding }, ); try { await sendEmail(recipient.email, subject, html, undefined, text, args.portId, [ { fileId: args.signedPdfFileId, filename: args.signedPdfFilename }, ]); logger.info( { portId: args.portId, recipient: recipient.email, documentLabel: args.documentLabel }, 'Signing-completed email sent', ); } catch (err) { logger.error( { err, portId: args.portId, recipient: recipient.email }, 'Signing-completed email send failed', ); // Don't throw — sending to one recipient shouldn't block the others. } }), ), ); }