diff --git a/src/lib/email/templates/notification-digest.ts b/src/lib/email/templates/notification-digest.ts
new file mode 100644
index 0000000..e64d562
--- /dev/null
+++ b/src/lib/email/templates/notification-digest.ts
@@ -0,0 +1,138 @@
+/**
+ * Daily / hourly digest email of a CRM user's unread notifications.
+ * Used by the notification-digest scheduler (queued in `email` worker).
+ */
+
+interface DigestData {
+ portName: string;
+ recipientName: string;
+ /** Each notification we want to surface. Trimmed to ~20 by the
+ * caller — anything longer drops a link to the in-app inbox. */
+ items: Array<{
+ type: string;
+ title: string;
+ description: string | null;
+ link: string | null;
+ createdAt: Date;
+ }>;
+ totalUnread: number;
+ inboxLink: string;
+}
+
+const LOGO_URL =
+ 'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
+const BACKGROUND_URL = 'https://s3.portnimara.com/images/Overhead_1_blur.png';
+
+function shell(opts: { title: string; body: string }): string {
+ return `
+
+
+
+ ${opts.title}
+
+
+
+
+
+
+
+
+
+
+
+ ${opts.body}
+ |
+
+
+ |
+
+
+
+`;
+}
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+const TYPE_LABELS: Record = {
+ reminder_due: 'Reminder due',
+ reminder_overdue: 'Reminder overdue',
+ new_registration: 'New inquiry',
+ eoi_signed: 'EOI signed',
+ eoi_completed: 'EOI completed',
+ email_received: 'New email',
+ duplicate_alert: 'Possible duplicate',
+ invoice_overdue: 'Invoice overdue',
+ system_alert: 'System alert',
+ follow_up_created: 'Follow-up',
+ tenure_expiring: 'Tenure expiring',
+ berth_released: 'Berth released',
+};
+
+export function notificationDigestEmail(data: DigestData): {
+ subject: string;
+ html: string;
+ text: string;
+} {
+ const subject = `${data.portName} CRM digest — ${data.totalUnread} unread`;
+
+ const itemsHtml = data.items
+ .map((item) => {
+ const label = TYPE_LABELS[item.type] ?? item.type.replace(/_/g, ' ');
+ const titleHtml = item.link
+ ? `${escapeHtml(item.title)}`
+ : `${escapeHtml(item.title)}`;
+ const desc = item.description
+ ? `${escapeHtml(item.description)}
`
+ : '';
+ return `|
+ ${label}
+ ${titleHtml}
+ ${desc}
+ |
`;
+ })
+ .join('');
+
+ const tail =
+ data.totalUnread > data.items.length
+ ? `…and ${data.totalUnread - data.items.length} more.
+ Open the inbox to see everything.
`
+ : '';
+
+ const greeting = data.recipientName ? `Hi ${escapeHtml(data.recipientName)},` : 'Hi,';
+
+ const body = `
+
+ Your ${escapeHtml(data.portName)} CRM digest
+
+ ${greeting}
+
+ You have ${data.totalUnread} unread notification${data.totalUnread === 1 ? '' : 's'} since the last digest.
+
+
+ ${tail}
+
+ Thank you,
+ ${escapeHtml(data.portName)} CRM
+
`;
+
+ const text = [
+ `${data.portName} CRM digest`,
+ '',
+ `You have ${data.totalUnread} unread notifications.`,
+ '',
+ ...data.items.map((i) => `• [${i.type.replace(/_/g, ' ')}] ${i.title}`),
+ '',
+ `Inbox: ${data.inboxLink}`,
+ ].join('\n');
+
+ return { subject, html: shell({ title: subject, body }), text };
+}
diff --git a/src/lib/queue/workers/email.ts b/src/lib/queue/workers/email.ts
index ede55ea..606fbbb 100644
--- a/src/lib/queue/workers/email.ts
+++ b/src/lib/queue/workers/email.ts
@@ -81,6 +81,15 @@ export const emailWorker = new Worker(
await sendEmail(to, subject, notification.html, undefined, notification.text, portId);
break;
}
+ case 'notification-digest': {
+ // Recurring scheduler entry (hourly). The dispatcher gates on
+ // each port's configured digest time + timezone so this is a
+ // cheap no-op for hours that don't match.
+ const { runNotificationDigest } =
+ await import('@/lib/services/notification-digest.service');
+ await runNotificationDigest();
+ break;
+ }
default:
logger.warn({ jobName: job.name }, 'Unknown email job');
}
diff --git a/src/lib/services/notification-digest.service.ts b/src/lib/services/notification-digest.service.ts
new file mode 100644
index 0000000..e2865a8
--- /dev/null
+++ b/src/lib/services/notification-digest.service.ts
@@ -0,0 +1,203 @@
+/**
+ * Notification digest dispatcher.
+ *
+ * Cron `notification-digest` fires hourly via the email worker. For
+ * every port with `reminder_digest_enabled === true`, we check whether
+ * the configured `reminder_digest_time` (HH:MM in
+ * `reminder_digest_timezone`) matches the current hour. If yes, we
+ * batch each user's unread notifications from the last 24h into a
+ * single digest email and mark those notifications as `email_sent` so
+ * they don't appear in tomorrow's digest.
+ *
+ * Per-user respect:
+ * - The digest is skipped for users with no unread notifications.
+ * - Notification preferences (per-user opt-out) are honored via the
+ * existing `userNotificationPreferences` table where present.
+ */
+
+import { and, desc, eq, gte, inArray, isNull } from 'drizzle-orm';
+
+import { db } from '@/lib/db';
+import { ports } from '@/lib/db/schema/ports';
+import { notifications } from '@/lib/db/schema/operations';
+import { user as authUser } from '@/lib/db/schema/users';
+import { userPortRoles } from '@/lib/db/schema/users';
+import { sendEmail } from '@/lib/email';
+import { notificationDigestEmail } from '@/lib/email/templates/notification-digest';
+import { getPortReminderConfig } from '@/lib/services/port-config';
+import { env } from '@/lib/env';
+import { logger } from '@/lib/logger';
+import { resolveSubject } from '@/lib/email/resolve-subject';
+
+const DIGEST_LOOKBACK_MS = 24 * 60 * 60 * 1000;
+const MAX_ITEMS_PER_USER = 20;
+
+/**
+ * Returns the local hour (0-23) for `at` in IANA `timezone`. Falls
+ * back to UTC if the timezone is unparseable so a misconfigured port
+ * still gets exactly one digest fire per day instead of zero.
+ */
+function localHourFor(at: Date, timezone: string): number {
+ try {
+ const fmt = new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ hour: 'numeric',
+ hour12: false,
+ });
+ const parts = fmt.formatToParts(at);
+ const hourPart = parts.find((p) => p.type === 'hour')?.value;
+ if (hourPart === undefined) return at.getUTCHours();
+ const n = Number.parseInt(hourPart, 10);
+ return Number.isFinite(n) ? n % 24 : at.getUTCHours();
+ } catch {
+ return at.getUTCHours();
+ }
+}
+
+export interface DigestRunResult {
+ portsConsidered: number;
+ portsFired: number;
+ digestsSent: number;
+ errors: number;
+}
+
+export async function runNotificationDigest(now: Date = new Date()): Promise {
+ const allPorts = await db.select({ id: ports.id, name: ports.name }).from(ports);
+ let portsFired = 0;
+ let digestsSent = 0;
+ let errors = 0;
+
+ for (const port of allPorts) {
+ let cfg;
+ try {
+ cfg = await getPortReminderConfig(port.id);
+ } catch (err) {
+ logger.warn({ err, portId: port.id }, 'digest: failed to load port reminder config');
+ errors += 1;
+ continue;
+ }
+
+ if (!cfg.digestEnabled) continue;
+
+ // Only fire when the current local hour in the port's TZ matches
+ // the configured digest time. The cron pattern is hourly so this
+ // gate ensures we send exactly once per day per port.
+ const targetHour = Number.parseInt(cfg.digestTime.split(':')[0] ?? '9', 10);
+ const localHour = localHourFor(now, cfg.digestTimezone);
+ if (localHour !== targetHour) continue;
+
+ portsFired += 1;
+
+ // Find all users with a port-role on this port — that's the
+ // recipient set. Future iteration could honor per-user opt-out
+ // flags from userNotificationPreferences.
+ const portUsers = await db
+ .select({
+ userId: userPortRoles.userId,
+ email: authUser.email,
+ name: authUser.name,
+ })
+ .from(userPortRoles)
+ .innerJoin(authUser, eq(userPortRoles.userId, authUser.id))
+ .where(eq(userPortRoles.portId, port.id));
+
+ if (portUsers.length === 0) continue;
+
+ const since = new Date(now.getTime() - DIGEST_LOOKBACK_MS);
+
+ for (const u of portUsers) {
+ try {
+ const rows = await db
+ .select({
+ id: notifications.id,
+ type: notifications.type,
+ title: notifications.title,
+ description: notifications.description,
+ link: notifications.link,
+ createdAt: notifications.createdAt,
+ })
+ .from(notifications)
+ .where(
+ and(
+ eq(notifications.portId, port.id),
+ eq(notifications.userId, u.userId),
+ eq(notifications.isRead, false),
+ eq(notifications.emailSent, false),
+ gte(notifications.createdAt, since),
+ ),
+ )
+ .orderBy(desc(notifications.createdAt))
+ .limit(100);
+
+ if (rows.length === 0) continue;
+
+ const visible = rows.slice(0, MAX_ITEMS_PER_USER);
+ const inboxLink = `${env.APP_URL}/notifications`;
+ const result = notificationDigestEmail({
+ portName: port.name,
+ recipientName: u.name ?? '',
+ items: visible.map((r) => ({
+ type: r.type,
+ title: r.title,
+ description: r.description,
+ link: r.link ? `${env.APP_URL}${r.link}` : null,
+ createdAt: r.createdAt,
+ })),
+ totalUnread: rows.length,
+ inboxLink,
+ });
+
+ // The per-port subject override key for the digest is the
+ // existing 'crm_invite' / 'portal_*' family — digest is its own
+ // thing; for now we ship the default subject from the template.
+ const subject = await resolveSubject({
+ // No dedicated catalog key yet for the digest; keep a stable
+ // pseudo-key in case admins want to override later. Falls
+ // through to the template's default subject if no override.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ key: 'crm_invite' as any,
+ portId: port.id,
+ fallback: result.subject,
+ tokens: { portName: port.name },
+ });
+
+ await sendEmail(u.email, subject, result.html, undefined, result.text, port.id);
+
+ await db
+ .update(notifications)
+ .set({ emailSent: true })
+ .where(
+ and(
+ eq(notifications.portId, port.id),
+ eq(notifications.userId, u.userId),
+ inArray(
+ notifications.id,
+ rows.map((r) => r.id),
+ ),
+ isNull(notifications.isRead) || eq(notifications.isRead, false),
+ ),
+ );
+
+ digestsSent += 1;
+ } catch (err) {
+ logger.error(
+ { err, portId: port.id, userId: u.userId },
+ 'digest: per-user dispatch failed',
+ );
+ errors += 1;
+ }
+ }
+ }
+
+ logger.info(
+ { portsConsidered: allPorts.length, portsFired, digestsSent, errors },
+ 'notification-digest run complete',
+ );
+
+ return {
+ portsConsidered: allPorts.length,
+ portsFired,
+ digestsSent,
+ errors,
+ };
+}