diff --git a/src/components/admin/settings/settings-manager.tsx b/src/components/admin/settings/settings-manager.tsx
index e6793eee..55ceab96 100644
--- a/src/components/admin/settings/settings-manager.tsx
+++ b/src/components/admin/settings/settings-manager.tsx
@@ -163,6 +163,14 @@ const KNOWN_SETTINGS: Array<{
type: 'recipients',
defaultValue: [],
},
+ {
+ key: 'signing_notification_recipients',
+ label: 'Document signing alerts',
+ description:
+ 'Who gets emailed each time a party signs an EOI / contract and when a document is fully signed: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Add yourself and sales@ here. Falls back to the Reply-To address when empty.',
+ type: 'recipients',
+ defaultValue: [],
+ },
{
key: 'eoi_signers',
label: 'EOI Signers',
diff --git a/src/lib/email/templates/signing-status-notification.tsx b/src/lib/email/templates/signing-status-notification.tsx
new file mode 100644
index 00000000..ca62403e
--- /dev/null
+++ b/src/lib/email/templates/signing-status-notification.tsx
@@ -0,0 +1,152 @@
+/**
+ * Internal "signing progress" alert — sent to the port's configured
+ * signing-notification recipients (e.g. the admin + sales@) so staff get
+ * a heads-up every time a party signs and again when a document is fully
+ * signed. This is the CRM equivalent of the legacy "Document Signed" /
+ * "EOI Complete Update Status" Activepieces flows.
+ *
+ * Two events:
+ * - `signed` — a single party just signed (carries who + progress).
+ * - `completed` — all parties have signed.
+ *
+ * Unlike the signer-facing templates, this one links back into the CRM
+ * (deep link to the document) rather than to a signing page — the
+ * recipients are staff, not signers.
+ */
+
+import { Button, Hr, Link, Text, render } from '@react-email/components';
+import * as React from 'react';
+
+import { brandingPrimaryColor, renderShell, safeUrl, type BrandingShell } from '@/lib/email/shell';
+
+interface RenderOpts {
+ subject?: string | null;
+ branding?: BrandingShell | null;
+}
+
+export interface StatusNotificationData {
+ event: 'signed' | 'completed';
+ documentLabel: string;
+ /** Deal / client name for the salutation + subject context. */
+ clientName: string;
+ portName: string;
+ /** Deep link into the CRM document detail page. */
+ crmUrl: string;
+ /** For `signed`: who just signed. */
+ signerName?: string | null;
+ signerRole?: string | null;
+ /** For `signed`: progress within the signing order. */
+ signedCount?: number;
+ totalCount?: number;
+}
+
+function roleLabel(role?: string | null): string {
+ switch (role) {
+ case 'client':
+ return 'the client';
+ case 'developer':
+ return 'the developer';
+ case 'approver':
+ return 'the approver';
+ case 'witness':
+ return 'a witness';
+ default:
+ return 'a signer';
+ }
+}
+
+function StatusBody({ data, accent }: { data: StatusNotificationData; accent: string }) {
+ const isCompleted = data.event === 'completed';
+ const progress =
+ typeof data.signedCount === 'number' && typeof data.totalCount === 'number'
+ ? `${data.signedCount} of ${data.totalCount}`
+ : null;
+
+ const heading = isCompleted
+ ? `${data.documentLabel} fully signed`
+ : `${data.signerName ?? 'A signer'} has signed`;
+
+ return (
+ <>
+
+ {heading}
+
+ {isCompleted ? (
+
+ The {data.documentLabel} for {data.clientName} has now been signed by all
+ parties. The fully signed PDF has been filed against the deal in the {data.portName} CRM.
+
+ ) : (
+
+ {data.signerName ?? 'A signer'} ({roleLabel(data.signerRole)}) has signed
+ the {data.documentLabel} for {data.clientName}
+ {progress ? ` — ${progress} signatures collected so far.` : '.'}
+
+ )}
+
+
+
+
+
+ Open the deal:{' '}
+
+ {data.crmUrl}
+
+
+
+ You're receiving this because you're on the signing-notification list for{' '}
+ {data.portName}. An administrator can change who gets these alerts in CRM settings.
+
+ >
+ );
+}
+
+export async function signingStatusNotificationEmail(
+ data: StatusNotificationData,
+ overrides?: RenderOpts,
+): Promise<{ subject: string; html: string; text: string }> {
+ const accent = brandingPrimaryColor(overrides?.branding);
+ const isCompleted = data.event === 'completed';
+
+ const subject = overrides?.subject
+ ? overrides.subject
+ .replace(/\{\{documentLabel\}\}/g, data.documentLabel)
+ .replace(/\{\{clientName\}\}/g, data.clientName)
+ .replace(/\{\{portName\}\}/g, data.portName)
+ : isCompleted
+ ? `${data.documentLabel} fully signed — ${data.clientName}`
+ : `${data.signerName ?? 'A signer'} signed the ${data.documentLabel} — ${data.clientName}`;
+
+ const body = await render(, { pretty: false });
+
+ const progress =
+ typeof data.signedCount === 'number' && typeof data.totalCount === 'number'
+ ? ` (${data.signedCount} of ${data.totalCount} signed)`
+ : '';
+ const text = isCompleted
+ ? `The ${data.documentLabel} for ${data.clientName} has been signed by all parties and filed in the ${data.portName} CRM.\n\nView in CRM: ${data.crmUrl}`
+ : `${data.signerName ?? 'A signer'} (${roleLabel(data.signerRole)}) has signed the ${data.documentLabel} for ${data.clientName}${progress}.\n\nView in CRM: ${data.crmUrl}`;
+
+ return {
+ subject,
+ html: renderShell({ title: subject, body, branding: overrides?.branding }),
+ text,
+ };
+}
diff --git a/src/lib/services/document-signing-emails.service.ts b/src/lib/services/document-signing-emails.service.ts
index a69e2017..4d69c16e 100644
--- a/src/lib/services/document-signing-emails.service.ts
+++ b/src/lib/services/document-signing-emails.service.ts
@@ -38,8 +38,10 @@ import {
signingInvitationEmail,
signingReminderEmail,
} from '@/lib/email/templates/document-signing';
+import { signingStatusNotificationEmail } from '@/lib/email/templates/signing-status-notification';
import { getPortDocumensoConfig } from '@/lib/services/port-config';
import { extractSigningToken } from '@/lib/services/documenso-signers';
+import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
import { logger } from '@/lib/logger';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -329,3 +331,78 @@ export async function sendSigningCancelled(args: SigningCancelledArgs): Promise<
),
);
}
+
+// ─── Internal status notifications (staff "who signed" alerts) ────────────────
+
+export interface SigningStatusNotificationArgs {
+ portId: string;
+ portName: string;
+ /** `signed` = one party just signed; `completed` = all parties done. */
+ event: 'signed' | 'completed';
+ documentLabel: string;
+ /** Deal / client name for context in the subject + body. */
+ clientName: string;
+ /** Deep link into the CRM document detail page. */
+ crmUrl: string;
+ /** For `signed`: who just signed + their role + running progress. */
+ signerName?: string | null;
+ signerRole?: SignerRole | null;
+ signedCount?: number;
+ totalCount?: number;
+}
+
+/**
+ * Notify the port's configured signing-notification recipients (the admin
+ * + sales@, plus any extras) that a party signed or that a document is
+ * fully signed. CRM equivalent of the legacy "Document Signed" /
+ * "EOI Complete Update Status" Activepieces flows.
+ *
+ * Recipients come from the `signing_notification_recipients` setting
+ * (users / roles / emails), falling back to the port's reply-to address
+ * (`email_reply_to`) so the alert is never silently dropped. No-op when
+ * nothing resolves. Per-recipient send so the internal list isn't exposed
+ * across recipients; failures are logged, never thrown (the webhook /
+ * completion path must not be undone by an email hiccup).
+ */
+export async function sendSigningStatusNotification(
+ args: SigningStatusNotificationArgs,
+): Promise {
+ const recipients = await resolveNotificationRecipients(
+ args.portId,
+ 'signing_notification_recipients',
+ 'email_reply_to',
+ );
+ if (recipients.length === 0) return;
+
+ const branding = await getBrandingShell(args.portId);
+ const { subject, html, text } = await signingStatusNotificationEmail(
+ {
+ event: args.event,
+ documentLabel: args.documentLabel,
+ clientName: args.clientName,
+ portName: args.portName,
+ crmUrl: args.crmUrl,
+ signerName: args.signerName ?? null,
+ signerRole: args.signerRole ?? null,
+ signedCount: args.signedCount,
+ totalCount: args.totalCount,
+ },
+ { branding },
+ );
+
+ const sendLimit = pLimit(3);
+ await Promise.all(
+ recipients.map((to) =>
+ sendLimit(async () => {
+ try {
+ await sendEmail(to, subject, html, undefined, text, args.portId);
+ } catch (err) {
+ logger.error(
+ { err, portId: args.portId, recipient: to, event: args.event },
+ 'Signing status notification send failed',
+ );
+ }
+ }),
+ ),
+ );
+}
diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts
index 5e5598eb..04bea46b 100644
--- a/src/lib/services/documents.service.ts
+++ b/src/lib/services/documents.service.ts
@@ -45,6 +45,7 @@ import {
import {
sendSigningInvitation,
sendSigningCompleted,
+ sendSigningStatusNotification,
type SignerRole,
} from '@/lib/services/document-signing-emails.service';
import {
@@ -1248,6 +1249,14 @@ export async function handleRecipientSigned(eventData: {
'cascading "your turn" invite failed after recipient signed',
);
});
+
+ // Internal "who signed" alert to the port's signing-notification
+ // recipients (admin + sales@). Fire-and-forget + fully guarded inside
+ // the helper so it can't undo the signing that just succeeded.
+ void notifySigningStatus(doc, 'signed', {
+ name: signer.signerName,
+ role: signer.signerRole,
+ });
}
}
@@ -1345,6 +1354,70 @@ async function sendCascadingInviteForNextSigner(doc: {
}
}
+/**
+ * Fire the internal "signing progress" alert to the port's configured
+ * signing-notification recipients (admin + sales@, etc). Self-contained
+ * and fully guarded — a notification failure must never undo a signing /
+ * completion side effect, so all errors are swallowed + logged. Resolves
+ * the deal client name + a deep CRM link, and (for `signed`) the running
+ * signed/total progress.
+ */
+async function notifySigningStatus(
+ doc: Parameters[1] & {
+ id: string;
+ portId: string;
+ documentType: string;
+ title: string;
+ },
+ event: 'signed' | 'completed',
+ signer?: { name: string; role: string | null } | null,
+): Promise {
+ try {
+ const port = await db.query.ports.findFirst({
+ where: eq(ports.id, doc.portId),
+ columns: { name: true, slug: true },
+ });
+
+ let clientName = doc.title;
+ const owner = await resolveDocumentOwner(doc.portId, doc);
+ if (owner?.entityType === 'client') {
+ const client = await db.query.clients.findFirst({
+ where: eq(clients.id, owner.entityId),
+ columns: { fullName: true },
+ });
+ if (client?.fullName) clientName = client.fullName;
+ }
+
+ let signedCount: number | undefined;
+ let totalCount: number | undefined;
+ if (event === 'signed') {
+ const all = await db
+ .select({ status: documentSigners.status })
+ .from(documentSigners)
+ .where(eq(documentSigners.documentId, doc.id));
+ totalCount = all.length;
+ signedCount = all.filter((s) => s.status === 'signed').length;
+ }
+
+ const crmUrl = `${env.APP_URL ?? ''}/${port?.slug ?? ''}/documents/${doc.id}`;
+
+ await sendSigningStatusNotification({
+ portId: doc.portId,
+ portName: port?.name ?? 'Port Nimara',
+ event,
+ documentLabel: DOC_TYPE_LABEL[doc.documentType] ?? 'Expression of Interest',
+ clientName,
+ crmUrl,
+ signerName: signer?.name ?? null,
+ signerRole: (signer?.role as SignerRole) ?? null,
+ signedCount,
+ totalCount,
+ });
+ } catch (err) {
+ logger.error({ err, documentId: doc.id, event }, 'signing status notification failed');
+ }
+}
+
/**
* Manually (re)send the finalized signed PDF of a completed document to the
* deal's client. Mirrors the automatic completion fan-out (sendSigningCompleted)
@@ -1924,6 +1997,11 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
}),
);
}
+
+ // Internal "fully signed" alert to the port's signing-notification
+ // recipients (admin + sales@). handleDocumentCompleted is idempotent
+ // (early-returns on re-delivery), so this fires exactly once per doc.
+ void notifySigningStatus(doc, 'completed');
}
export async function handleDocumentExpired(eventData: { documentId: string; portId?: string }) {
diff --git a/tests/unit/email/signing-status-notification.test.ts b/tests/unit/email/signing-status-notification.test.ts
new file mode 100644
index 00000000..b70e67ae
--- /dev/null
+++ b/tests/unit/email/signing-status-notification.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+
+import { signingStatusNotificationEmail } from '@/lib/email/templates/signing-status-notification';
+
+describe('signingStatusNotificationEmail', () => {
+ it('renders a per-signer "has signed" alert with progress + CRM link', async () => {
+ const { subject, html, text } = await signingStatusNotificationEmail({
+ event: 'signed',
+ documentLabel: 'Expression of Interest',
+ clientName: 'Jane Doe',
+ portName: 'Port Nimara',
+ crmUrl: 'https://crm.portnimara.com/port-nimara/documents/abc',
+ signerName: 'Jane Doe',
+ signerRole: 'client',
+ signedCount: 1,
+ totalCount: 3,
+ });
+
+ // Subject names who signed + the deal so sales can triage at a glance.
+ expect(subject).toContain('Jane Doe');
+ expect(subject).toContain('signed');
+ // Body states the signing event, the document, and progress.
+ expect(html).toContain('Jane Doe');
+ expect(html).toContain('has signed');
+ expect(html).toContain('Expression of Interest');
+ expect(html).toContain('1 of 3');
+ // Internal recipients get a deep link into the CRM, not a signing link.
+ expect(html).toContain('https://crm.portnimara.com/port-nimara/documents/abc');
+ expect(text).toContain('Jane Doe');
+ expect(text).toContain('1 of 3');
+ });
+
+ it('renders a completion alert when all parties have signed', async () => {
+ const { subject, html, text } = await signingStatusNotificationEmail({
+ event: 'completed',
+ documentLabel: 'Sales Contract',
+ clientName: 'Acme Holdings',
+ portName: 'Port Nimara',
+ crmUrl: 'https://crm.portnimara.com/port-nimara/documents/xyz',
+ });
+
+ expect(subject).toContain('Acme Holdings');
+ expect(subject.toLowerCase()).toContain('fully signed');
+ expect(html).toContain('all parties');
+ expect(html).toContain('Sales Contract');
+ expect(html).toContain('Acme Holdings');
+ expect(html).toContain('https://crm.portnimara.com/port-nimara/documents/xyz');
+ expect(text).toContain('Acme Holdings');
+ });
+});
diff --git a/tests/unit/services/signing-status-notification.service.test.ts b/tests/unit/services/signing-status-notification.service.test.ts
new file mode 100644
index 00000000..d133ef32
--- /dev/null
+++ b/tests/unit/services/signing-status-notification.service.test.ts
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Boundaries the sender depends on — mock the I/O edges, exercise the real
+// wiring (recipient resolution → template → per-recipient send).
+vi.mock('@/lib/email', () => ({ sendEmail: vi.fn().mockResolvedValue(undefined) }));
+vi.mock('@/lib/email/branding-resolver', () => ({
+ getBrandingShell: vi.fn().mockResolvedValue(null),
+}));
+vi.mock('@/lib/services/notification-recipients', () => ({
+ resolveNotificationRecipients: vi.fn(),
+}));
+
+import { sendEmail } from '@/lib/email';
+import { resolveNotificationRecipients } from '@/lib/services/notification-recipients';
+import { sendSigningStatusNotification } from '@/lib/services/document-signing-emails.service';
+
+const mockSendEmail = vi.mocked(sendEmail);
+const mockResolve = vi.mocked(resolveNotificationRecipients);
+
+const baseArgs = {
+ portId: 'port-1',
+ portName: 'Port Nimara',
+ event: 'signed' as const,
+ documentLabel: 'Expression of Interest',
+ clientName: 'Jane Doe',
+ crmUrl: 'https://crm.portnimara.com/port-nimara/documents/abc',
+ signerName: 'Jane Doe',
+ signerRole: 'client' as const,
+ signedCount: 1,
+ totalCount: 3,
+};
+
+describe('sendSigningStatusNotification', () => {
+ beforeEach(() => {
+ mockSendEmail.mockClear();
+ mockResolve.mockReset();
+ });
+
+ it('emails every configured recipient when a signer signs', async () => {
+ mockResolve.mockResolvedValue(['admin@portnimara.com', 'sales@portnimara.com']);
+
+ await sendSigningStatusNotification(baseArgs);
+
+ // Resolves from the signing list, falling back to the reply-to address.
+ expect(mockResolve).toHaveBeenCalledWith(
+ 'port-1',
+ 'signing_notification_recipients',
+ 'email_reply_to',
+ );
+ expect(mockSendEmail).toHaveBeenCalledTimes(2);
+ const recipients = mockSendEmail.mock.calls.map((c) => c[0]);
+ expect(recipients).toContain('admin@portnimara.com');
+ expect(recipients).toContain('sales@portnimara.com');
+ // Subject reflects who signed.
+ const subject = mockSendEmail.mock.calls[0]?.[1] as string;
+ expect(subject).toContain('Jane Doe');
+ // portId threaded through so per-port From + redirect apply.
+ expect(mockSendEmail.mock.calls[0]?.[5]).toBe('port-1');
+ });
+
+ it('sends nothing when no recipients are configured or resolvable', async () => {
+ mockResolve.mockResolvedValue([]);
+
+ await sendSigningStatusNotification(baseArgs);
+
+ expect(mockSendEmail).not.toHaveBeenCalled();
+ });
+
+ it('uses the completion subject for the completed event', async () => {
+ mockResolve.mockResolvedValue(['sales@portnimara.com']);
+
+ await sendSigningStatusNotification({
+ ...baseArgs,
+ event: 'completed',
+ signerName: null,
+ });
+
+ const subject = mockSendEmail.mock.calls[0]?.[1] as string;
+ expect(subject.toLowerCase()).toContain('fully signed');
+ expect(subject).toContain('Jane Doe');
+ });
+});