From c1c1d755cefc8c55c476771d4fa1cc2b71d0f51d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 15:39:59 +0000 Subject: [PATCH 1/2] feat: add RootEmailNotificationPlugin Adds a notification service that sends email when CMS actions occur, plus a standalone EmailClient for sending email from arbitrary server-side code. - New `RootEmailNotificationPlugin` factory at `@blinkk/root-cms/email-notification`. Subscribes to `onAction()` via the existing notification service hook. - Recipients can be email addresses, role specifiers (e.g. `'role:ADMINS'`), or functions; user-configurable filters (`include`/`exclude` globs or a function) gate which actions trigger email. - Plain-text email templates with `{{var}}` interpolation, looked up per-action and via prefix glob (e.g. `'tasks.*'`). - New `EmailClient` at `@blinkk/root-cms/email-client` queues email documents in Firestore for delivery by the existing root-services Go worker. Adds cc/bcc/replyTo support to the worker. - Tasks special case: for `tasks.*` actions, subscribers stored on the task doc and `@` mentions extracted from comments are automatically notified. - Adds `subscribers` field to `Task`, with subscribe/unsubscribe API and UI controls in the task detail page; comment authors are auto-subscribed. https://claude.ai/code/session_01JSn1UwdSeg9oDqfgJ4yBup --- .changeset/email-notification-plugin.md | 13 + apps/root-services/main.go | 13 + packages/root-cms/core/email-client.ts | 212 +++++++ .../core/email-notification-plugin.test.ts | 461 ++++++++++++++ .../core/email-notification-plugin.ts | 588 ++++++++++++++++++ packages/root-cms/core/plugin.ts | 23 +- packages/root-cms/core/tsup.config.ts | 4 + packages/root-cms/package.json | 8 + .../root-cms/ui/pages/TaskPage/TaskPage.css | 78 +++ .../root-cms/ui/pages/TaskPage/TaskPage.tsx | 176 ++++++ packages/root-cms/ui/utils/tasks.test.ts | 105 ++++ packages/root-cms/ui/utils/tasks.ts | 129 +++- 12 files changed, 1806 insertions(+), 4 deletions(-) create mode 100644 .changeset/email-notification-plugin.md create mode 100644 packages/root-cms/core/email-client.ts create mode 100644 packages/root-cms/core/email-notification-plugin.test.ts create mode 100644 packages/root-cms/core/email-notification-plugin.ts create mode 100644 packages/root-cms/ui/utils/tasks.test.ts diff --git a/.changeset/email-notification-plugin.md b/.changeset/email-notification-plugin.md new file mode 100644 index 000000000..d8eff685d --- /dev/null +++ b/.changeset/email-notification-plugin.md @@ -0,0 +1,13 @@ +--- +'@blinkk/root-cms': minor +--- + +feat: add `RootEmailNotificationPlugin` for email notifications on CMS actions + +- New `RootEmailNotificationPlugin` factory at `@blinkk/root-cms/email-notification` registers a notification service that subscribes to the `onAction()` hook. +- Recipients can be defined as email addresses, role specifiers (e.g. `'role:ADMINS'`), or functions that compute recipients from the action. +- Filter actions in/out via `include`/`exclude` glob patterns or a custom function. +- User-configurable plain-text email templates with `{{var}}` interpolation, including per-action and prefix-glob (e.g. `'tasks.*'`) lookups. +- Dedicated `EmailClient` at `@blinkk/root-cms/email-client` for sending email from any server-side code; emails are queued in Firestore and dispatched by the existing `apps/root-services` Go worker. +- Tasks special case: for `tasks.*` actions, subscribers stored on the task doc plus `@` mentions extracted from comments are automatically notified in addition to any configured recipients. +- New `subscribers` field on `Task`, with subscribe/unsubscribe controls in the task detail page and auto-subscribe on comment. diff --git a/apps/root-services/main.go b/apps/root-services/main.go index 42425ae60..9ff0513ec 100644 --- a/apps/root-services/main.go +++ b/apps/root-services/main.go @@ -231,6 +231,19 @@ func processPendingEmails(ctx context.Context, fsClient *firestore.Client, proje msg.To = append(msg.To, fmt.Sprintf("%v", t)) } } + if ccList, ok := data["cc"].([]interface{}); ok { + for _, c := range ccList { + msg.Cc = append(msg.Cc, fmt.Sprintf("%v", c)) + } + } + if bccList, ok := data["bcc"].([]interface{}); ok { + for _, b := range bccList { + msg.Bcc = append(msg.Bcc, fmt.Sprintf("%v", b)) + } + } + if replyTo, ok := data["replyTo"].(string); ok && replyTo != "" { + msg.ReplyTo = replyTo + } // Include HTML body if present. if htmlBody, ok := data["htmlBody"].(string); ok && htmlBody != "" { diff --git a/packages/root-cms/core/email-client.ts b/packages/root-cms/core/email-client.ts new file mode 100644 index 000000000..06a5f54a1 --- /dev/null +++ b/packages/root-cms/core/email-client.ts @@ -0,0 +1,212 @@ +import type {RootConfig} from '@blinkk/root'; +import {Firestore, Timestamp} from 'firebase-admin/firestore'; +import {RootCMSClient} from './client.js'; + +/** + * Default time-to-live for queued emails. After this elapses without delivery, + * the email is marked as `expired` by the email worker (see + * `apps/root-services/main.go`). + */ +const DEFAULT_EMAIL_TTL_MS = 24 * 60 * 60 * 1000; + +/** Status values written to the `Emails` queue. */ +export type EmailStatus = 'pending' | 'sent' | 'failed' | 'expired'; + +/** Options for {@link EmailClient.send}. */ +export interface SendEmailOptions { + /** Sender address (e.g. `noreply@example.com`). Required. */ + from: string; + /** One or more recipient addresses. */ + to: string | string[]; + /** Subject line. */ + subject: string; + /** Plain-text body. */ + body: string; + /** + * Optional HTML body. If omitted, only the plain-text body is delivered. + * The email worker treats the text body as the source of truth. + */ + htmlBody?: string; + /** Optional reply-to address. */ + replyTo?: string; + /** Optional list of CC addresses. */ + cc?: string | string[]; + /** Optional list of BCC addresses. */ + bcc?: string | string[]; + /** + * Optional tags to attach to the queued email document for downstream + * filtering (e.g. analytics, auditing). Stored on the Firestore doc. + */ + tags?: string[]; + /** + * Time-to-live override in milliseconds. If the email has not been sent + * within this window, the worker marks it as `expired` instead of sending. + * Defaults to 24h. + */ + ttlMs?: number; +} + +/** A row representing a queued email document in Firestore. */ +export interface EmailDoc { + id: string; + from: string; + to: string[]; + cc?: string[]; + bcc?: string[]; + replyTo?: string; + subject: string; + body: string; + htmlBody?: string; + tags?: string[]; + status: EmailStatus; + createdAt: Timestamp; + expiredAt?: Timestamp; + sentAt?: Timestamp; + error?: string; +} + +/** Result returned by {@link EmailClient.send}. */ +export interface SendEmailResult { + /** Firestore doc id assigned to the queued email. */ + id: string; +} + +function toArray(value: string | string[] | undefined): string[] { + if (!value) { + return []; + } + if (Array.isArray(value)) { + return value.filter(Boolean); + } + return [value]; +} + +function dedupeEmails(emails: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const email of emails) { + const normalized = email.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(email.trim()); + } + return result; +} + +/** + * Client library for sending emails from server-side code (plugins, cron jobs, + * api handlers, etc). + * + * Emails are written to a Firestore queue at + * `Projects//Emails`. A separate worker process (e.g. the Go app + * under `apps/root-services`) reads pending docs and dispatches them via + * App Engine Mail. + * + * Example: + * ```ts + * const client = new EmailClient(rootConfig); + * await client.send({ + * from: 'noreply@example.com', + * to: 'team@example.com', + * subject: 'Hello', + * body: 'Plain text body.', + * }); + * ``` + */ +export class EmailClient { + readonly cmsClient: RootCMSClient; + readonly db: Firestore; + readonly projectId: string; + + constructor(rootConfigOrClient: RootConfig | RootCMSClient) { + if (rootConfigOrClient instanceof RootCMSClient) { + this.cmsClient = rootConfigOrClient; + } else { + this.cmsClient = new RootCMSClient(rootConfigOrClient); + } + this.db = this.cmsClient.db; + this.projectId = this.cmsClient.projectId; + } + + /** + * Queues an email for delivery. Returns the assigned Firestore doc id. + * + * The email is not sent inline. Instead, a Firestore document is written to + * `Projects//Emails` with `status: 'pending'`. The email worker + * picks it up, dispatches the message, and updates `status` to `sent`, + * `failed`, or `expired`. + */ + async send(options: SendEmailOptions): Promise { + if (!options.from) { + throw new Error('email "from" is required'); + } + if (!options.subject) { + throw new Error('email "subject" is required'); + } + if (!options.body) { + throw new Error('email "body" is required'); + } + const to = dedupeEmails(toArray(options.to)); + if (to.length === 0) { + throw new Error('email "to" must include at least one recipient'); + } + const cc = dedupeEmails(toArray(options.cc)); + const bcc = dedupeEmails(toArray(options.bcc)); + + const ttlMs = options.ttlMs ?? DEFAULT_EMAIL_TTL_MS; + const now = Timestamp.now(); + const expiredAt = Timestamp.fromMillis(now.toMillis() + ttlMs); + + const colRef = this.db.collection(`Projects/${this.projectId}/Emails`); + const docRef = colRef.doc(); + const data: Record = { + id: docRef.id, + from: options.from, + to, + subject: options.subject, + body: options.body, + status: 'pending' as EmailStatus, + createdAt: now, + expiredAt, + }; + if (cc.length > 0) { + data.cc = cc; + } + if (bcc.length > 0) { + data.bcc = bcc; + } + if (options.replyTo) { + data.replyTo = options.replyTo; + } + if (options.htmlBody) { + data.htmlBody = options.htmlBody; + } + if (options.tags && options.tags.length > 0) { + data.tags = options.tags; + } + await docRef.set(data); + return {id: docRef.id}; + } + + /** + * Returns the most recent emails in the queue. Useful for debugging and + * admin tooling. By default returns the 50 most recent emails. + */ + async listRecent(options?: { + limit?: number; + status?: EmailStatus; + }): Promise { + const limit = options?.limit ?? 50; + let query = this.db + .collection(`Projects/${this.projectId}/Emails`) + .orderBy('createdAt', 'desc') + .limit(limit); + if (options?.status) { + query = query.where('status', '==', options.status) as typeof query; + } + const snapshot = await query.get(); + return snapshot.docs.map((doc) => doc.data() as EmailDoc); + } +} diff --git a/packages/root-cms/core/email-notification-plugin.test.ts b/packages/root-cms/core/email-notification-plugin.test.ts new file mode 100644 index 000000000..821f6849d --- /dev/null +++ b/packages/root-cms/core/email-notification-plugin.test.ts @@ -0,0 +1,461 @@ +import {RootConfig} from '@blinkk/root'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +vi.mock('firebase-admin/app', () => ({ + getApp: vi.fn(), + initializeApp: vi.fn(), + applicationDefault: vi.fn(), +})); + +vi.mock('firebase-admin/firestore', () => { + const Timestamp = { + now: () => ({ + toMillis: () => 1700000000000, + toDate: () => new Date(1700000000000), + }), + fromMillis: (millis: number) => ({ + toMillis: () => millis, + toDate: () => new Date(millis), + }), + fromDate: (date: Date) => ({ + toMillis: () => date.getTime(), + toDate: () => date, + }), + }; + return { + getFirestore: vi.fn(), + Timestamp, + FieldValue: {}, + }; +}); + +vi.mock('./project.js', () => ({ + getCollectionSchema: vi.fn(), +})); + +function createMockFirestore(initial: Record = {}) { + const sentDocs: any[] = []; + const docs = new Map(Object.entries(initial)); + const db: any = { + collection: vi.fn((path: string) => { + let counter = 0; + return { + doc: (id?: string) => { + counter += 1; + const docId = id || `mock-doc-${counter}`; + const fullPath = `${path}/${docId}`; + return { + id: docId, + set: vi.fn(async (data: any) => { + sentDocs.push({path: fullPath, data}); + docs.set(fullPath, data); + }), + get: vi.fn(async () => ({ + exists: docs.has(fullPath), + data: () => docs.get(fullPath) || {}, + })), + }; + }, + orderBy: () => ({ + limit: () => ({ + get: vi.fn(async () => ({docs: []})), + where: () => ({get: vi.fn(async () => ({docs: []}))}), + }), + }), + }; + }), + doc: vi.fn((path: string) => ({ + id: path.split('/').pop(), + get: vi.fn(async () => ({ + exists: docs.has(path), + data: () => docs.get(path), + })), + })), + }; + return {db, sentDocs, docs}; +} + +function makeRootConfig(db: any): RootConfig { + return { + rootDir: '/test', + plugins: [ + { + name: 'root-cms', + getConfig: () => ({ + id: 'test-project', + firebaseConfig: { + apiKey: 'k', + authDomain: 'a', + projectId: 'test-project', + storageBucket: 's', + }, + }), + getFirebaseApp: () => ({}), + getFirestore: () => db, + } as any, + ], + } as any; +} + +describe('RootEmailNotificationPlugin', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('queues an email when an action matches the include filter', async () => { + const {db, sentDocs} = createMockFirestore({ + 'Projects/test-project': {roles: {'admin@example.com': 'ADMIN'}}, + }); + const rootConfig = makeRootConfig(db); + const {RootCMSClient} = await import('./client.js'); + const cmsClient = new RootCMSClient(rootConfig); + + const {RootEmailNotificationPlugin} = await import( + './email-notification-plugin.js' + ); + const service = RootEmailNotificationPlugin({ + from: 'noreply@example.com', + recipients: 'team@example.com', + filter: {include: ['doc.publish']}, + }); + + const {Timestamp} = await import('firebase-admin/firestore'); + const result = await service.onAction!( + {rootConfig, cmsClient, user: {email: 'editor@example.com'}}, + { + action: 'doc.publish', + by: 'editor@example.com', + timestamp: Timestamp.now(), + metadata: {docId: 'Pages/foo'}, + } + ); + + expect(result?.status).toBe('success'); + expect(sentDocs).toHaveLength(1); + expect(sentDocs[0].path).toMatch(/Projects\/test-project\/Emails\//); + expect(sentDocs[0].data.to).toEqual(['team@example.com']); + expect(sentDocs[0].data.from).toBe('noreply@example.com'); + expect(sentDocs[0].data.subject).toContain('doc.publish'); + }); + + it('skips actions excluded by the filter', async () => { + const {db, sentDocs} = createMockFirestore(); + const rootConfig = makeRootConfig(db); + const {RootCMSClient} = await import('./client.js'); + const cmsClient = new RootCMSClient(rootConfig); + + const {RootEmailNotificationPlugin} = await import( + './email-notification-plugin.js' + ); + const service = RootEmailNotificationPlugin({ + from: 'noreply@example.com', + recipients: 'team@example.com', + filter: {exclude: ['doc.save']}, + }); + + const {Timestamp} = await import('firebase-admin/firestore'); + const result = await service.onAction!( + {rootConfig, cmsClient}, + { + action: 'doc.save', + timestamp: Timestamp.now(), + } + ); + + expect(result?.status).toBe('info'); + expect(sentDocs).toHaveLength(0); + }); + + it('resolves role:ADMINS to ACL members', async () => { + const {db, sentDocs} = createMockFirestore({ + 'Projects/test-project': { + roles: { + 'admin1@example.com': 'ADMIN', + 'admin2@example.com': 'ADMIN', + 'editor@example.com': 'EDITOR', + '*@example.com': 'VIEWER', + }, + }, + }); + const rootConfig = makeRootConfig(db); + const {RootCMSClient} = await import('./client.js'); + const cmsClient = new RootCMSClient(rootConfig); + + const {RootEmailNotificationPlugin} = await import( + './email-notification-plugin.js' + ); + const service = RootEmailNotificationPlugin({ + from: 'noreply@example.com', + recipients: ['role:ADMINS'], + }); + + const {Timestamp} = await import('firebase-admin/firestore'); + await service.onAction!( + {rootConfig, cmsClient}, + {action: 'doc.publish', timestamp: Timestamp.now()} + ); + + expect(sentDocs).toHaveLength(1); + expect(sentDocs[0].data.to).toEqual([ + 'admin1@example.com', + 'admin2@example.com', + ]); + }); + + it('excludes the actor by default', async () => { + const {db, sentDocs} = createMockFirestore({ + 'Projects/test-project': { + roles: { + 'a@example.com': 'ADMIN', + 'b@example.com': 'ADMIN', + }, + }, + }); + const rootConfig = makeRootConfig(db); + const {RootCMSClient} = await import('./client.js'); + const cmsClient = new RootCMSClient(rootConfig); + + const {RootEmailNotificationPlugin} = await import( + './email-notification-plugin.js' + ); + const service = RootEmailNotificationPlugin({ + from: 'noreply@example.com', + recipients: ['role:ADMINS'], + }); + + const {Timestamp} = await import('firebase-admin/firestore'); + await service.onAction!( + {rootConfig, cmsClient}, + { + action: 'doc.publish', + by: 'a@example.com', + timestamp: Timestamp.now(), + } + ); + + expect(sentDocs[0].data.to).toEqual(['b@example.com']); + }); + + it('automatically adds task subscribers and mentions for tasks.* actions', async () => { + const {db, sentDocs} = createMockFirestore({ + 'Projects/test-project/Tasks/42': { + id: '42', + subscribers: ['watcher@example.com'], + assignee: 'assignee@example.com', + createdBy: 'creator@example.com', + }, + }); + const rootConfig = makeRootConfig(db); + const {RootCMSClient} = await import('./client.js'); + const cmsClient = new RootCMSClient(rootConfig); + + const {RootEmailNotificationPlugin} = await import( + './email-notification-plugin.js' + ); + const service = RootEmailNotificationPlugin({ + from: 'noreply@example.com', + recipients: ['extra@example.com'], + excludeActor: false, + }); + + const {Timestamp} = await import('firebase-admin/firestore'); + await service.onAction!( + {rootConfig, cmsClient}, + { + action: 'tasks.comment.add', + timestamp: Timestamp.now(), + metadata: { + taskId: '42', + mentions: ['mentioned@example.com'], + }, + } + ); + + expect(sentDocs).toHaveLength(1); + const to = sentDocs[0].data.to as string[]; + expect(to).toEqual( + expect.arrayContaining([ + 'extra@example.com', + 'watcher@example.com', + 'assignee@example.com', + 'creator@example.com', + 'mentioned@example.com', + ]) + ); + }); + + it('renders templates with {{interpolation}}', async () => { + const {db, sentDocs} = createMockFirestore(); + const rootConfig = makeRootConfig(db); + const {RootCMSClient} = await import('./client.js'); + const cmsClient = new RootCMSClient(rootConfig); + + const {RootEmailNotificationPlugin} = await import( + './email-notification-plugin.js' + ); + const service = RootEmailNotificationPlugin({ + from: 'noreply@example.com', + recipients: 'team@example.com', + templates: { + 'doc.publish': { + subject: 'Published: {{metadata.docId}}', + body: '{{by}} published {{metadata.docId}}.', + }, + }, + }); + + const {Timestamp} = await import('firebase-admin/firestore'); + await service.onAction!( + {rootConfig, cmsClient}, + { + action: 'doc.publish', + by: 'jane@example.com', + timestamp: Timestamp.now(), + metadata: {docId: 'Pages/index'}, + } + ); + + expect(sentDocs[0].data.subject).toBe('Published: Pages/index'); + expect(sentDocs[0].data.body).toBe( + 'jane@example.com published Pages/index.' + ); + }); + + it('matches templates by prefix glob', async () => { + const {db, sentDocs} = createMockFirestore(); + const rootConfig = makeRootConfig(db); + const {RootCMSClient} = await import('./client.js'); + const cmsClient = new RootCMSClient(rootConfig); + + const {RootEmailNotificationPlugin} = await import( + './email-notification-plugin.js' + ); + const service = RootEmailNotificationPlugin({ + from: 'noreply@example.com', + recipients: 'team@example.com', + templates: { + 'tasks.*': { + subject: 'Task update', + body: 'Action: {{action}}', + }, + }, + }); + + const {Timestamp} = await import('firebase-admin/firestore'); + await service.onAction!( + {rootConfig, cmsClient}, + { + action: 'tasks.create', + timestamp: Timestamp.now(), + metadata: {taskId: '1'}, + } + ); + + expect(sentDocs[0].data.subject).toBe('Task update'); + expect(sentDocs[0].data.body).toBe('Action: tasks.create'); + }); + + it('returns info status when no recipients resolve', async () => { + const {db, sentDocs} = createMockFirestore({ + 'Projects/test-project': {roles: {}}, + }); + const rootConfig = makeRootConfig(db); + const {RootCMSClient} = await import('./client.js'); + const cmsClient = new RootCMSClient(rootConfig); + + const {RootEmailNotificationPlugin} = await import( + './email-notification-plugin.js' + ); + const service = RootEmailNotificationPlugin({ + from: 'noreply@example.com', + recipients: ['role:ADMIN'], + }); + + const {Timestamp} = await import('firebase-admin/firestore'); + const result = await service.onAction!( + {rootConfig, cmsClient}, + {action: 'doc.publish', timestamp: Timestamp.now()} + ); + + expect(result?.status).toBe('info'); + expect(sentDocs).toHaveLength(0); + }); + + it('throws when recipients is empty', async () => { + const {RootEmailNotificationPlugin} = await import( + './email-notification-plugin.js' + ); + expect(() => + RootEmailNotificationPlugin({ + from: 'noreply@example.com', + recipients: [], + }) + ).toThrow(/recipients/); + }); + + it('throws when from is missing', async () => { + const {RootEmailNotificationPlugin} = await import( + './email-notification-plugin.js' + ); + expect(() => + RootEmailNotificationPlugin({ + from: '', + recipients: 'a@example.com', + }) + ).toThrow(/from/); + }); +}); + +describe('EmailClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('queues an email with required fields', async () => { + const {db, sentDocs} = createMockFirestore(); + const rootConfig = makeRootConfig(db); + const {EmailClient} = await import('./email-client.js'); + const client = new EmailClient(rootConfig); + const result = await client.send({ + from: 'a@example.com', + to: 'b@example.com', + subject: 'Hi', + body: 'Hello.', + }); + expect(result.id).toBeTruthy(); + expect(sentDocs).toHaveLength(1); + const data = sentDocs[0].data; + expect(data.from).toBe('a@example.com'); + expect(data.to).toEqual(['b@example.com']); + expect(data.status).toBe('pending'); + }); + + it('dedupes recipients', async () => { + const {db, sentDocs} = createMockFirestore(); + const rootConfig = makeRootConfig(db); + const {EmailClient} = await import('./email-client.js'); + const client = new EmailClient(rootConfig); + await client.send({ + from: 'a@example.com', + to: ['b@example.com', 'B@EXAMPLE.COM', 'c@example.com'], + subject: 'Hi', + body: 'Hello.', + }); + expect(sentDocs[0].data.to).toEqual(['b@example.com', 'c@example.com']); + }); + + it('throws if recipients are empty', async () => { + const {db} = createMockFirestore(); + const rootConfig = makeRootConfig(db); + const {EmailClient} = await import('./email-client.js'); + const client = new EmailClient(rootConfig); + await expect( + client.send({ + from: 'a@example.com', + to: [], + subject: 'Hi', + body: 'Hello.', + }) + ).rejects.toThrow(/recipient/); + }); +}); diff --git a/packages/root-cms/core/email-notification-plugin.ts b/packages/root-cms/core/email-notification-plugin.ts new file mode 100644 index 000000000..0be1a7203 --- /dev/null +++ b/packages/root-cms/core/email-notification-plugin.ts @@ -0,0 +1,588 @@ +import {Action, RootCMSClient, UserRole} from './client.js'; +import {EmailClient, SendEmailOptions} from './email-client.js'; +import type { + CMSNotificationService, + NotificationResult, + NotificationServiceContext, +} from './services-notifications.js'; + +/** + * A recipient specifier. Can be: + * - An email address string, e.g. `"foo@example.com"`. + * - A role reference, e.g. `"role:ADMIN"` or `"role:ADMINS"` (plural is + * accepted as an alias). Resolves to all members of the project ACL with + * that role. + * - The literal string `"task:subscribers"`, which resolves to the + * subscribers and mentioned users of the task referenced in the action + * metadata. Only meaningful for `tasks.*` actions. + * - A function that receives the notification context and the action and + * returns a list of email addresses (or a Promise of one). + */ +export type EmailNotificationRecipient = + | string + | (( + ctx: NotificationServiceContext, + action: Action + ) => string[] | Promise); + +/** + * Filter spec controlling which actions trigger an email. + * + * - `include`: only actions matching one of these names (or prefixes ending + * in `*`, e.g. `"doc.*"`) trigger emails. If omitted, all actions are + * eligible. + * - `exclude`: actions matching any of these names (or prefixes) are + * skipped. Applied after `include`. + * - A function form receives the action and returns `true` to send. + */ +export type EmailNotificationFilter = + | {include?: string[]; exclude?: string[]} + | ((action: Action) => boolean | Promise); + +/** + * Context passed to a {@link EmailNotificationTemplate} function. Provides the + * raw action plus the resolved recipients so templates can vary copy when + * needed (e.g. a CC line). + */ +export interface EmailTemplateContext extends NotificationServiceContext { + /** The action that triggered the notification. */ + action: Action; + /** Resolved recipient list (deduplicated, lowercased). */ + recipients: string[]; +} + +/** + * Output of a template function. `subject` and `body` are required. The + * notification plugin will not produce HTML email — bodies are plain text + * only by design. + */ +export interface EmailTemplateOutput { + subject: string; + body: string; +} + +/** + * A template can be either an object with literal `subject`/`body` strings + * containing `{{var}}` interpolations, or a function that returns the + * template output (sync or async). + * + * Available interpolation variables: + * - `{{action}}` — the action name (e.g. `"doc.publish"`). + * - `{{by}}` — the email of the user that performed the action. + * - `{{timestamp}}` — ISO 8601 timestamp. + * - `{{metadata.}}` — any field on the action metadata, e.g. + * `{{metadata.taskId}}`. + * - `{{recipients}}` — comma-separated list of recipient emails. + */ +export type EmailNotificationTemplate = + | {subject: string; body: string} + | (( + ctx: EmailTemplateContext + ) => EmailTemplateOutput | Promise); + +/** Options accepted by {@link createEmailNotificationService}. */ +export interface RootEmailNotificationPluginOptions { + /** Service id. Defaults to `"email"`. */ + id?: string; + /** Service label shown in the CMS UI. Defaults to `"Email"`. */ + label?: string; + /** Optional icon URL displayed alongside the label in the CMS UI. */ + icon?: string; + /** + * Sender address used on outgoing email, e.g. `"noreply@example.com"`. + * Required. + */ + from: string; + /** Optional reply-to address applied to all sent email. */ + replyTo?: string; + /** + * Recipients of the notification. May be a single recipient or an array. + * See {@link EmailNotificationRecipient} for accepted shapes. + */ + recipients: EmailNotificationRecipient | EmailNotificationRecipient[]; + /** + * Filter controlling which actions are emailed. Defaults to "send for all + * actions". See {@link EmailNotificationFilter}. + */ + filter?: EmailNotificationFilter; + /** + * Per-action templates. Look up order: + * 1. Exact action name (e.g. `"doc.publish"`). + * 2. Prefix match (e.g. `"tasks.*"` matches `"tasks.create"`). + * 3. The `default` key. + * + * If no template matches, a built-in default template is used. + */ + templates?: Record; + /** + * If true, the user that triggered the action is excluded from the + * recipient list (people generally don't want to be emailed about their own + * changes). Defaults to `true`. Set to `false` to opt out of this. + */ + excludeActor?: boolean; + /** + * Optional list of action names that recipients can opt out of via the + * project-level email preferences doc. Recipients added through this + * mechanism are checked against the per-user `emailPreferences` field at + * `Projects//Users/` and skipped if they have opted out. + * + * Currently a hook for future extension; defaults to honoring all + * preferences if present. + */ + honorUserPreferences?: boolean; +} + +/** + * Default template applied when no per-action template matches. + */ +const DEFAULT_TEMPLATE: EmailNotificationTemplate = { + subject: '[Root CMS] {{action}}', + body: [ + 'Hi,', + '', + 'A "{{action}}" action was performed in the CMS by {{by}} at {{timestamp}}.', + '', + 'Metadata:', + '{{metadata.json}}', + '', + '— Root CMS', + ].join('\n'), +}; + +/** + * The plugin reserved key for the special "task subscribers + mentions" + * recipient. + */ +export const TASK_SUBSCRIBERS_RECIPIENT = 'task:subscribers'; + +function normalizeArray(value: T | T[] | undefined): T[] { + if (value === undefined) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +function matchesActionGlob(action: string, pattern: string): boolean { + if (pattern === action) { + return true; + } + if (pattern.endsWith('.*')) { + const prefix = pattern.slice(0, -1); // Keep trailing dot. + return action.startsWith(prefix); + } + if (pattern === '*') { + return true; + } + return false; +} + +function matchesAny(action: string, patterns: string[]): boolean { + return patterns.some((pattern) => matchesActionGlob(action, pattern)); +} + +async function applyFilter( + filter: EmailNotificationFilter | undefined, + action: Action +): Promise { + if (!filter) { + return true; + } + if (typeof filter === 'function') { + return Boolean(await filter(action)); + } + if (filter.include && filter.include.length > 0) { + if (!matchesAny(action.action, filter.include)) { + return false; + } + } + if (filter.exclude && filter.exclude.length > 0) { + if (matchesAny(action.action, filter.exclude)) { + return false; + } + } + return true; +} + +/** + * Resolves a `role:` recipient to the matching emails in the project ACL. + * Both singular and plural forms are accepted (e.g. `role:ADMIN` and + * `role:ADMINS`). + */ +async function resolveRoleRecipient( + cmsClient: RootCMSClient, + roleSpec: string +): Promise { + const roleStr = roleSpec.slice('role:'.length).toUpperCase(); + // Strip optional trailing 'S' so 'ADMINS' === 'ADMIN'. + const role = ( + roleStr.endsWith('S') ? roleStr.slice(0, -1) : roleStr + ) as UserRole; + const docRef = cmsClient.db.doc(`Projects/${cmsClient.projectId}`); + const snapshot = await docRef.get(); + const data = snapshot.data() || {}; + const acl = (data.roles || {}) as Record; + const emails: string[] = []; + for (const [email, userRole] of Object.entries(acl)) { + if (userRole === role && !email.startsWith('*@')) { + emails.push(email); + } + } + return emails; +} + +/** + * Resolves the special `task:subscribers` recipient. Returns the union of + * subscribers stored on the task doc and `@mentions` from the action + * metadata (for `tasks.comment.add` / `tasks.comment.edit`). + */ +async function resolveTaskRecipients( + cmsClient: RootCMSClient, + action: Action +): Promise { + if (!action.action.startsWith('tasks.')) { + return []; + } + const taskId = action.metadata?.taskId; + if (!taskId) { + return []; + } + const emails = new Set(); + + // Mentions are supplied directly on the action metadata (set by + // ui/utils/tasks.ts when adding/editing comments). + const mentions: string[] = Array.isArray(action.metadata?.mentions) + ? action.metadata.mentions + : []; + for (const mention of mentions) { + if (typeof mention === 'string' && mention.includes('@')) { + emails.add(mention.toLowerCase()); + } + } + + // Read the task doc for stored subscribers. + try { + const taskRef = cmsClient.db.doc( + `Projects/${cmsClient.projectId}/Tasks/${taskId}` + ); + const snapshot = await taskRef.get(); + const data = snapshot.data() || {}; + const subscribers: unknown = data.subscribers; + if (Array.isArray(subscribers)) { + for (const sub of subscribers) { + if (typeof sub === 'string' && sub.includes('@')) { + emails.add(sub.toLowerCase()); + } + } + } + // The task assignee and creator are also notified by default. + for (const key of ['assignee', 'createdBy']) { + const value = data[key]; + if (typeof value === 'string' && value.includes('@')) { + emails.add(value.toLowerCase()); + } + } + } catch (err) { + console.error('failed to read task subscribers:', err); + } + + return Array.from(emails); +} + +async function resolveRecipient( + ctx: NotificationServiceContext, + recipient: EmailNotificationRecipient, + action: Action +): Promise { + if (typeof recipient === 'function') { + return await recipient(ctx, action); + } + if (recipient.startsWith('role:')) { + return await resolveRoleRecipient(ctx.cmsClient, recipient); + } + if (recipient === TASK_SUBSCRIBERS_RECIPIENT) { + return await resolveTaskRecipients(ctx.cmsClient, action); + } + return [recipient]; +} + +function dedupeLower(emails: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const email of emails) { + if (!email) { + continue; + } + const normalized = email.trim().toLowerCase(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(normalized); + } + return result; +} + +/** + * Looks up a value at a dot-separated path on `obj`. Returns `undefined` if + * any segment is missing. + */ +function lookupPath(obj: any, path: string): unknown { + if (!obj) { + return undefined; + } + const parts = path.split('.'); + let cur: any = obj; + for (const part of parts) { + if (cur === null || cur === undefined) { + return undefined; + } + cur = cur[part]; + } + return cur; +} + +function formatValue(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + return String(value); +} + +/** Replaces `{{var}}` tokens in a template string. */ +function interpolate( + template: string, + variables: Record +): string { + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_match, key) => { + if (key === 'metadata.json') { + return formatValue(variables.metadata); + } + return formatValue(lookupPath(variables, key)); + }); +} + +function findTemplate( + templates: Record | undefined, + actionName: string +): EmailNotificationTemplate { + if (!templates) { + return DEFAULT_TEMPLATE; + } + if (templates[actionName]) { + return templates[actionName]; + } + // Try prefix matches like `tasks.*`. + const prefixMatches = Object.keys(templates) + .filter((key) => key.endsWith('.*')) + .sort((a, b) => b.length - a.length); // Longest match wins. + for (const key of prefixMatches) { + if (matchesActionGlob(actionName, key)) { + return templates[key]; + } + } + if (templates.default) { + return templates.default; + } + return DEFAULT_TEMPLATE; +} + +async function renderTemplate( + template: EmailNotificationTemplate, + ctx: EmailTemplateContext +): Promise { + if (typeof template === 'function') { + return await template(ctx); + } + const variables = { + action: ctx.action.action, + by: ctx.action.by || 'system', + timestamp: ctx.action.timestamp?.toDate?.()?.toISOString() || '', + metadata: ctx.action.metadata || {}, + recipients: ctx.recipients.join(', '), + }; + return { + subject: interpolate(template.subject, variables), + body: interpolate(template.body, variables), + }; +} + +async function readUserOptOuts( + cmsClient: RootCMSClient, + emails: string[], + actionName: string +): Promise> { + if (emails.length === 0) { + return new Set(); + } + const optedOut = new Set(); + // Read each user prefs doc in parallel. Missing docs are treated as + // "subscribed to all". + await Promise.all( + emails.map(async (email) => { + try { + const ref = cmsClient.db.doc( + `Projects/${cmsClient.projectId}/EmailPreferences/${email}` + ); + const snapshot = await ref.get(); + if (!snapshot.exists) { + return; + } + const data = snapshot.data() || {}; + const optOuts: unknown = data.optOut; + if (Array.isArray(optOuts) && matchesAny(actionName, optOuts)) { + optedOut.add(email); + } + } catch (err) { + // Best-effort: a failure here should not block delivery. + console.error(`failed to read email prefs for ${email}:`, err); + } + }) + ); + return optedOut; +} + +/** + * Creates a {@link CMSNotificationService} that sends email when CMS actions + * fire. Pass the returned object to `cmsPlugin({notifications: [...]})`. + * + * Example: + * ```ts + * import {cmsPlugin} from '@blinkk/root-cms/plugin'; + * import {RootEmailNotificationPlugin} from '@blinkk/root-cms/email-notification'; + * + * cmsPlugin({ + * notifications: [ + * RootEmailNotificationPlugin({ + * from: 'noreply@example.com', + * recipients: ['role:ADMINS', 'editor@example.com'], + * filter: {include: ['doc.publish', 'tasks.*']}, + * templates: { + * 'doc.publish': { + * subject: 'Doc {{metadata.docId}} published', + * body: '{{by}} just published {{metadata.docId}}.', + * }, + * }, + * }), + * ], + * }); + * ``` + */ +export function RootEmailNotificationPlugin( + options: RootEmailNotificationPluginOptions +): CMSNotificationService { + if (!options.from) { + throw new Error('RootEmailNotificationPlugin: "from" is required'); + } + if ( + options.recipients === undefined || + options.recipients === null || + (Array.isArray(options.recipients) && options.recipients.length === 0) + ) { + throw new Error( + 'RootEmailNotificationPlugin: "recipients" must include at least one entry' + ); + } + const id = options.id || 'email'; + const label = options.label || 'Email'; + const recipients = normalizeArray(options.recipients); + const excludeActor = options.excludeActor !== false; + const honorUserPreferences = options.honorUserPreferences !== false; + + return { + id, + label, + icon: options.icon, + onAction: async ( + ctx: NotificationServiceContext, + action: Action + ): Promise => { + // Step 1: filter actions. + const allowed = await applyFilter(options.filter, action); + if (!allowed) { + return { + status: 'info', + message: `skipped: action "${action.action}" filtered out`, + }; + } + + // Step 2: resolve recipients. + const resolvedLists = await Promise.all( + recipients.map((r) => resolveRecipient(ctx, r, action)) + ); + // Special case: for `tasks.*` actions, always include subscribers and + // mentions even if the user did not list `task:subscribers` explicitly. + // This matches the documented "tasks special case" behavior. + if (action.action.startsWith('tasks.')) { + resolvedLists.push(await resolveTaskRecipients(ctx.cmsClient, action)); + } + let allRecipients = dedupeLower(resolvedLists.flat()); + + // Step 3: exclude actor and check per-user opt-outs. + if (excludeActor && action.by) { + const actor = action.by.toLowerCase(); + allRecipients = allRecipients.filter((email) => email !== actor); + } + if (honorUserPreferences) { + const optedOut = await readUserOptOuts( + ctx.cmsClient, + allRecipients, + action.action + ); + if (optedOut.size > 0) { + allRecipients = allRecipients.filter((email) => !optedOut.has(email)); + } + } + + if (allRecipients.length === 0) { + return { + status: 'info', + message: `skipped: no recipients for action "${action.action}"`, + }; + } + + // Step 4: render template. + const template = findTemplate(options.templates, action.action); + const templateCtx: EmailTemplateContext = { + ...ctx, + action, + recipients: allRecipients, + }; + const rendered = await renderTemplate(template, templateCtx); + + // Step 5: queue email. + const emailClient = new EmailClient(ctx.cmsClient); + const sendOptions: SendEmailOptions = { + from: options.from, + to: allRecipients, + subject: rendered.subject, + body: rendered.body, + tags: [`action:${action.action}`], + }; + if (options.replyTo) { + sendOptions.replyTo = options.replyTo; + } + try { + const result = await emailClient.send(sendOptions); + return { + status: 'success', + message: `queued email ${result.id} to ${allRecipients.length} recipient(s)`, + }; + } catch (err: any) { + return { + status: 'error', + message: `failed to queue email: ${err?.message || String(err)}`, + }; + } + }, + }; +} diff --git a/packages/root-cms/core/plugin.ts b/packages/root-cms/core/plugin.ts index fdd2a6d2a..4bf6938be 100644 --- a/packages/root-cms/core/plugin.ts +++ b/packages/root-cms/core/plugin.ts @@ -27,10 +27,10 @@ import {SSEEvent, SSESchemaChangedEvent} from '../shared/sse.js'; import {type RootAiModel} from './ai.js'; import {api} from './api.js'; import {type CMSCheck} from './checks.js'; -import {type CMSNotificationService} from './services-notifications.js'; -import {type CMSTranslationService} from './translations.js'; import {Action, RootCMSClient} from './client.js'; +import {type CMSNotificationService} from './services-notifications.js'; import {sse, SSEBroadcastFn} from './sse.js'; +import {type CMSTranslationService} from './translations.js'; export type { CMSCheck, @@ -46,6 +46,25 @@ export type { NotificationResult, NotificationServiceContext, } from './services-notifications.js'; +export {EmailClient} from './email-client.js'; +export type { + EmailDoc, + EmailStatus, + SendEmailOptions, + SendEmailResult, +} from './email-client.js'; +export { + RootEmailNotificationPlugin, + TASK_SUBSCRIBERS_RECIPIENT, +} from './email-notification-plugin.js'; +export type { + EmailNotificationFilter, + EmailNotificationRecipient, + EmailNotificationTemplate, + EmailTemplateContext, + EmailTemplateOutput, + RootEmailNotificationPluginOptions, +} from './email-notification-plugin.js'; export type { CMSTranslationService, TranslationExportResult, diff --git a/packages/root-cms/core/tsup.config.ts b/packages/root-cms/core/tsup.config.ts index 43849135c..c5e05937f 100644 --- a/packages/root-cms/core/tsup.config.ts +++ b/packages/root-cms/core/tsup.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ cli: './cli/cli.ts', client: './core/client.ts', core: './core/core.ts', + 'email-client': './core/email-client.ts', + 'email-notification-plugin': './core/email-notification-plugin.ts', functions: './core/functions.ts', plugin: './core/plugin.ts', project: './core/project.ts', @@ -18,6 +20,8 @@ export default defineConfig({ entry: [ './core/client.ts', './core/core.ts', + './core/email-client.ts', + './core/email-notification-plugin.ts', './core/functions.ts', './core/plugin.ts', './core/project.ts', diff --git a/packages/root-cms/package.json b/packages/root-cms/package.json index 617b3b0f3..df4a512d8 100644 --- a/packages/root-cms/package.json +++ b/packages/root-cms/package.json @@ -33,6 +33,14 @@ "types": "./dist/core.d.ts", "import": "./dist/core.js" }, + "./email-client": { + "types": "./dist/email-client.d.ts", + "import": "./dist/email-client.js" + }, + "./email-notification": { + "types": "./dist/email-notification-plugin.d.ts", + "import": "./dist/email-notification-plugin.js" + }, "./functions": { "types": "./dist/functions.d.ts", "import": "./dist/functions.js" diff --git a/packages/root-cms/ui/pages/TaskPage/TaskPage.css b/packages/root-cms/ui/pages/TaskPage/TaskPage.css index befecc238..1b3f716c2 100644 --- a/packages/root-cms/ui/pages/TaskPage/TaskPage.css +++ b/packages/root-cms/ui/pages/TaskPage/TaskPage.css @@ -252,6 +252,84 @@ padding: 16px; } +.TaskPage__subscribers { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; + padding: 16px; +} + +.TaskPage__subscribers__top { + align-items: center; + display: flex; + justify-content: space-between; +} + +.TaskPage__subscribers__label { + color: var(--color-text-gray); + font-family: var(--font-family-mono); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.TaskPage__subscribers__count { + background: #eff4ff; + border: 1px solid #b2ccff; + border-radius: 999px; + color: #175cd3; + font-family: var(--font-family-mono); + font-size: 11px; + font-weight: 700; + line-height: 1; + min-height: 20px; + padding: 4px 8px; +} + +.TaskPage__subscribers__list { + display: flex; + flex-direction: column; + gap: 4px; + list-style: none; + margin: 0; + padding: 0; +} + +.TaskPage__subscribers__item { + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; + padding: 4px 0; +} + +.TaskPage__subscribers__email { + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.TaskPage__subscribers__you { + color: var(--color-text-gray); + font-size: 12px; +} + +.TaskPage__subscribers__empty { + color: var(--color-text-gray); + font-size: 13px; + font-style: italic; +} + +.TaskPage__subscribers__form { + align-items: center; + display: grid; + gap: 8px; + grid-template-columns: minmax(0, 1fr) auto; +} + .TaskPage__metadata__field { display: flex; flex-direction: column; diff --git a/packages/root-cms/ui/pages/TaskPage/TaskPage.tsx b/packages/root-cms/ui/pages/TaskPage/TaskPage.tsx index 01dfc0916..ec774e79e 100644 --- a/packages/root-cms/ui/pages/TaskPage/TaskPage.tsx +++ b/packages/root-cms/ui/pages/TaskPage/TaskPage.tsx @@ -12,6 +12,8 @@ import { } from '@mantine/core'; import {showNotification} from '@mantine/notifications'; import { + IconBell, + IconBellOff, IconCalendar, IconCheck, IconCornerDownRight, @@ -22,6 +24,8 @@ import { IconPencil, IconTrash, IconUser, + IconUserMinus, + IconUserPlus, IconX, } from '@tabler/icons-preact'; import {Timestamp} from 'firebase/firestore'; @@ -46,17 +50,20 @@ import { addTaskAttachment, deleteTaskComment, editTaskComment, + isUserSubscribedToTask, normalizeTaskStatus, removeTaskAttachment, subscribeTask, subscribeTaskComments, subscribeTaskEvents, + subscribeToTask, Task, TaskAttachment, TaskComment, TaskEvent, TaskMetadataField, TaskPriority, + unsubscribeFromTask, updateTaskTitle, updateTaskDescription, updateTaskAssignee, @@ -143,6 +150,7 @@ export function TaskPage(props: {id: string}) { {formatTaskStatus(task.status)} )} + {task && } @@ -165,6 +173,7 @@ export function TaskPage(props: {id: string}) { )} @@ -644,6 +653,173 @@ function TaskMetadataPanel(props: {task: Task}) { ); } +/** + * Inline button that toggles the current user's subscription to the task. + */ +function TaskSubscribeButton(props: {task: Task}) { + const {task} = props; + const currentUserEmail = window.firebase.user.email || ''; + const subscribed = isUserSubscribedToTask(task, currentUserEmail); + const [saving, setSaving] = useState(false); + + async function toggleSubscription() { + if (!currentUserEmail) { + return; + } + setSaving(true); + try { + if (subscribed) { + await unsubscribeFromTask(task.id, currentUserEmail); + } else { + await subscribeToTask(task.id, currentUserEmail); + } + } catch (err) { + showNotification({ + title: 'Could not update subscription', + message: errorMessage(err), + color: 'red', + autoClose: false, + }); + } finally { + setSaving(false); + } + } + + return ( + + + + ); +} + +/** + * Sidebar panel showing the list of users subscribed to a task. Allows the + * current user to add or remove subscribers (typically themselves, but admins + * can add teammates by email too). + */ +function TaskSubscribersPanel(props: {task: Task}) { + const {task} = props; + const currentUserEmail = window.firebase.user.email || ''; + const subscribers = (task.subscribers || []).slice().sort(); + const [draftEmail, setDraftEmail] = useState(''); + const [saving, setSaving] = useState(false); + const [removingEmail, setRemovingEmail] = useState(''); + + async function addSubscriber(e: Event) { + e.preventDefault(); + const email = draftEmail.trim().toLowerCase(); + if (!email) { + return; + } + setSaving(true); + try { + await subscribeToTask(task.id, email); + setDraftEmail(''); + } catch (err) { + showNotification({ + title: 'Could not add subscriber', + message: errorMessage(err), + color: 'red', + autoClose: false, + }); + } finally { + setSaving(false); + } + } + + async function removeSubscriber(email: string) { + setRemovingEmail(email); + try { + await unsubscribeFromTask(task.id, email); + } catch (err) { + showNotification({ + title: 'Could not remove subscriber', + message: errorMessage(err), + color: 'red', + autoClose: false, + }); + } finally { + setRemovingEmail(''); + } + } + + return ( + +
+
Subscribers
+
{subscribers.length}
+
+ {subscribers.length > 0 ? ( +
    + {subscribers.map((email) => ( +
  • + + {formatTaskUser(email)} + {email === currentUserEmail && ( + (you) + )} + + + removeSubscriber(email)} + > + + + +
  • + ))} +
+ ) : ( +
+ No one is subscribed yet. +
+ )} +
+ ) => + setDraftEmail(e.currentTarget.value) + } + /> + + +
+ ); +} + /** Combines task comments and metadata changes into a single timeline. */ function TaskTimeline(props: { task: Task; diff --git a/packages/root-cms/ui/utils/tasks.test.ts b/packages/root-cms/ui/utils/tasks.test.ts new file mode 100644 index 000000000..fe2320e3b --- /dev/null +++ b/packages/root-cms/ui/utils/tasks.test.ts @@ -0,0 +1,105 @@ +import {describe, expect, it, vi} from 'vitest'; + +const mockProjectId = 'test-project-id'; +window.__ROOT_CTX = { + rootConfig: {projectId: mockProjectId}, +} as any; +window.firebase = { + db: {type: 'mock-db'}, + user: {email: 'tester@example.com'}, +} as any; + +vi.mock('firebase/firestore', () => ({ + arrayRemove: vi.fn(), + arrayUnion: vi.fn(), + collection: vi.fn(), + doc: vi.fn(), + FieldPath: vi.fn(), + getDoc: vi.fn(), + onSnapshot: vi.fn(), + orderBy: vi.fn(), + query: vi.fn(), + runTransaction: vi.fn(), + serverTimestamp: vi.fn(), + setDoc: vi.fn(), + Timestamp: { + now: () => ({toMillis: () => 0}), + fromDate: (d: Date) => ({toMillis: () => d.getTime(), toDate: () => d}), + }, + updateDoc: vi.fn(), +})); + +vi.mock('./actions.js', () => ({ + logAction: vi.fn(), +})); + +describe('extractMentions', () => { + it('extracts a single mention from a comment string', async () => { + const {extractMentions} = await import('./tasks.js'); + expect(extractMentions('hi @foo@example.com please review')).toEqual([ + 'foo@example.com', + ]); + }); + + it('extracts multiple mentions and dedupes', async () => { + const {extractMentions} = await import('./tasks.js'); + expect( + extractMentions('@a@example.com @b@example.com again @A@example.com') + ).toEqual(['a@example.com', 'b@example.com']); + }); + + it('returns an empty array when no mentions are present', async () => { + const {extractMentions} = await import('./tasks.js'); + expect(extractMentions('no mentions in this comment')).toEqual([]); + }); + + it('extracts mentions from rich text data', async () => { + const {extractMentions} = await import('./tasks.js'); + const data = { + blocks: [ + {type: 'paragraph', data: {text: 'cc @user@test.com here'}}, + {type: 'paragraph', data: {text: 'and @other@test.com'}}, + ], + time: 0, + version: 'test', + }; + expect(extractMentions(data as any)).toEqual([ + 'user@test.com', + 'other@test.com', + ]); + }); + + it('does not match emails without a leading @', async () => { + const {extractMentions} = await import('./tasks.js'); + expect(extractMentions('email me at foo@example.com')).toEqual([]); + }); +}); + +describe('isUserSubscribedToTask', () => { + it('returns true when the email is in subscribers', async () => { + const {isUserSubscribedToTask} = await import('./tasks.js'); + const task = { + id: '1', + title: 'Test', + createdAt: {toMillis: () => 0} as any, + createdBy: 'a@example.com', + subscribers: ['user@example.com'], + }; + expect(isUserSubscribedToTask(task as any, 'user@example.com')).toBe(true); + expect(isUserSubscribedToTask(task as any, 'USER@example.com')).toBe(true); + }); + + it('returns false when not subscribed', async () => { + const {isUserSubscribedToTask} = await import('./tasks.js'); + const task = { + id: '1', + title: 'Test', + createdAt: {toMillis: () => 0} as any, + createdBy: 'a@example.com', + subscribers: ['user@example.com'], + }; + expect(isUserSubscribedToTask(task as any, 'other@example.com')).toBe( + false + ); + }); +}); diff --git a/packages/root-cms/ui/utils/tasks.ts b/packages/root-cms/ui/utils/tasks.ts index c3223090b..0626cf106 100644 --- a/packages/root-cms/ui/utils/tasks.ts +++ b/packages/root-cms/ui/utils/tasks.ts @@ -1,6 +1,7 @@ import { FieldPath, Timestamp, + arrayRemove, arrayUnion, collection, doc, @@ -29,6 +30,12 @@ export interface Task { priority?: TaskPriority; status?: string; targetLaunchDate?: Timestamp | null; + /** + * Lower-cased emails of users subscribed to notifications for this task. + * Subscribers are notified (e.g. via the email notification plugin) when the + * task metadata changes or new comments are added. + */ + subscribers?: string[]; createdAt: Timestamp; createdBy: string; updatedAt?: Timestamp; @@ -558,6 +565,107 @@ export async function removeTaskAttachment( } } +/** + * Adds the given email to the task's `subscribers` list. Subscribed users are + * notified when the task changes (e.g. via the email notification plugin). + * Idempotent — adding an email that is already subscribed is a no-op. + */ +export async function subscribeToTask(taskId: string, email?: string) { + if (!taskId) { + throw new Error('missing task id'); + } + const normalized = (email || window.firebase.user.email || '') + .trim() + .toLowerCase(); + if (!normalized) { + throw new Error('missing subscriber email'); + } + await updateDoc(taskDocRef(taskId), { + subscribers: arrayUnion(normalized), + updatedAt: serverTimestamp(), + updatedBy: window.firebase.user.email || '', + }); + logAction('tasks.subscribe', {metadata: {taskId, email: normalized}}); +} + +/** + * Removes the given email from the task's `subscribers` list. Idempotent. + */ +export async function unsubscribeFromTask(taskId: string, email?: string) { + if (!taskId) { + throw new Error('missing task id'); + } + const normalized = (email || window.firebase.user.email || '') + .trim() + .toLowerCase(); + if (!normalized) { + throw new Error('missing subscriber email'); + } + await updateDoc(taskDocRef(taskId), { + subscribers: arrayRemove(normalized), + updatedAt: serverTimestamp(), + updatedBy: window.firebase.user.email || '', + }); + logAction('tasks.unsubscribe', {metadata: {taskId, email: normalized}}); +} + +/** Returns true if the given email is subscribed to the task. */ +export function isUserSubscribedToTask(task: Task, email?: string): boolean { + const normalized = (email || window.firebase.user.email || '') + .trim() + .toLowerCase(); + if (!normalized) { + return false; + } + return (task.subscribers || []) + .map((s) => s.toLowerCase()) + .includes(normalized); +} + +const MENTION_RE = /(?:^|[\s(])@([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})/gi; + +/** + * Extracts `@email` mentions from a comment string or rich-text body. + * Returns lower-cased, deduplicated emails. + */ +export function extractMentions(content: string | RichTextData): string[] { + let text: string; + if (typeof content === 'string') { + text = content; + } else { + text = stringifyRichTextForMentions(content); + } + const matches = new Set(); + let m: RegExpExecArray | null; + while ((m = MENTION_RE.exec(text)) !== null) { + matches.add(m[1].toLowerCase()); + } + return Array.from(matches); +} + +function stringifyRichTextForMentions(data: RichTextData | null): string { + if (!data?.blocks?.length) { + return ''; + } + const parts: string[] = []; + for (const block of data.blocks) { + const text = + typeof (block as any).data?.text === 'string' + ? (block as any).data.text + : ''; + parts.push(text.replace(/<[^>]+>/g, ' ')); + const items = (block as any).data?.items; + if (Array.isArray(items)) { + for (const item of items) { + if (typeof item?.content === 'string') { + parts.push(item.content.replace(/<[^>]+>/g, ' ')); + } + } + } + } + return parts.join(' '); +} + async function updateTaskMetadataField( taskId: string, field: TaskMetadataField, @@ -647,7 +755,9 @@ export async function addTaskComment( const commentRef = doc(taskCommentsCollectionRef(taskId)); const commentId = commentRef.id; - const mentions = (options?.mentions || []).map((m) => m.toLowerCase()); + const mentions = options?.mentions + ? options.mentions.map((m) => m.toLowerCase()) + : extractMentions(content); await setDoc(commentRef, { id: commentId, @@ -661,6 +771,19 @@ export async function addTaskComment( history: [], }); + // Auto-subscribe the comment author to future updates on this task. Best + // effort — failures here should not block the comment. + const authorEmail = (window.firebase.user.email || '').toLowerCase(); + if (authorEmail) { + try { + await updateDoc(taskDocRef(taskId), { + subscribers: arrayUnion(authorEmail), + }); + } catch (err) { + console.error('failed to auto-subscribe commenter:', err); + } + } + logAction('tasks.comment.add', { metadata: {taskId, commentId, parentId: parentId || null, mentions}, }); @@ -693,7 +816,9 @@ export async function editTaskComment( throw new Error('comment not found'); } const data = snapshot.data() as TaskComment; - const mentions = (options?.mentions || []).map((m) => m.toLowerCase()); + const mentions = options?.mentions + ? options.mentions.map((m) => m.toLowerCase()) + : extractMentions(content); await updateDoc(commentRef, { content: contentText, From 3e0a0a4b9de6f4214f6ff477f6c17c1a6f5aa7c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 00:53:33 +0000 Subject: [PATCH 2/2] chore(docs): wire RootEmailNotificationPlugin into docs project Adds an opt-in plugin wrapper at docs/plugins/email-notifications.ts and wires it into docs/root.config.ts so the email notification plugin can be tested end-to-end on rootjs.dev. - The plugin is enabled when EMAIL_NOTIFICATIONS_FROM is set; otherwise the plugin is omitted so local dev keeps working without extra setup. - Recipients are configurable via EMAIL_NOTIFICATIONS_RECIPIENTS (comma-separated emails or role:NAME refs); defaults to role:ADMIN. - Includes templates for doc.publish, tasks.create, tasks.comment.add, and a tasks.* fallback so we can verify each path. - Adds a small dev script (scripts/test_email_notification.mjs) that triggers a synthetic action via RootCMSClient.logAction, useful to inspect the queued email doc in Firestore. https://claude.ai/code/session_01JSn1UwdSeg9oDqfgJ4yBup --- docs/plugins/email-notifications.ts | 100 +++++++++++++++++++++++ docs/root.config.ts | 5 ++ docs/scripts/test_email_notification.mjs | 46 +++++++++++ 3 files changed, 151 insertions(+) create mode 100644 docs/plugins/email-notifications.ts create mode 100644 docs/scripts/test_email_notification.mjs diff --git a/docs/plugins/email-notifications.ts b/docs/plugins/email-notifications.ts new file mode 100644 index 000000000..2f191efee --- /dev/null +++ b/docs/plugins/email-notifications.ts @@ -0,0 +1,100 @@ +import { + RootEmailNotificationPlugin, + type CMSNotificationService, + type RootEmailNotificationPluginOptions, +} from '@blinkk/root-cms/plugin'; + +/** + * Wraps {@link RootEmailNotificationPlugin} with sensible defaults for the + * docs site. Lets us test the plugin end-to-end without polluting + * `root.config.ts` with too much detail. + * + * Configuration is driven by env vars: + * - `EMAIL_NOTIFICATIONS_FROM`: sender address (e.g. `noreply@example.com`). + * - `EMAIL_NOTIFICATIONS_RECIPIENTS`: comma-separated list of base + * recipients applied to all notifications. Each entry can be an email or a + * `role:` reference. + * + * If `EMAIL_NOTIFICATIONS_FROM` is not set, this returns `null` so the + * config can omit the plugin in environments without email. + */ +export function emailNotificationsPlugin( + overrides: Partial = {} +): CMSNotificationService | null { + const from = overrides.from || process.env.EMAIL_NOTIFICATIONS_FROM; + if (!from) { + return null; + } + + const baseRecipients = ( + process.env.EMAIL_NOTIFICATIONS_RECIPIENTS || 'role:ADMIN' + ) + .split(',') + .map((r) => r.trim()) + .filter(Boolean); + + return RootEmailNotificationPlugin({ + from, + label: 'Docs Email', + recipients: overrides.recipients ?? baseRecipients, + filter: overrides.filter ?? { + // Skip noisy doc-save events; everything else is fair game by default. + exclude: ['doc.save'], + }, + templates: { + 'doc.publish': { + subject: '[rootjs.dev] Published: {{metadata.docId}}', + body: [ + 'Hi,', + '', + '{{by}} just published {{metadata.docId}} on rootjs.dev.', + '', + 'View the doc in the CMS:', + 'https://rootjs.dev/cms/content/{{metadata.docId}}', + '', + '— Root CMS', + ].join('\n'), + }, + 'tasks.create': { + subject: '[rootjs.dev] Task #{{metadata.taskId}} created', + body: [ + 'Hi,', + '', + '{{by}} just created task #{{metadata.taskId}}.', + '', + 'View the task:', + 'https://rootjs.dev/cms/tasks/{{metadata.taskId}}', + '', + '— Root CMS', + ].join('\n'), + }, + 'tasks.comment.add': { + subject: '[rootjs.dev] New comment on task #{{metadata.taskId}}', + body: [ + 'Hi,', + '', + '{{by}} added a comment to task #{{metadata.taskId}}.', + '', + 'View the task:', + 'https://rootjs.dev/cms/tasks/{{metadata.taskId}}', + '', + '— Root CMS', + ].join('\n'), + }, + 'tasks.*': { + subject: '[rootjs.dev] Task #{{metadata.taskId}} updated', + body: [ + 'Hi,', + '', + '{{by}} performed "{{action}}" on task #{{metadata.taskId}}.', + '', + 'View the task:', + 'https://rootjs.dev/cms/tasks/{{metadata.taskId}}', + '', + '— Root CMS', + ].join('\n'), + }, + }, + ...overrides, + }); +} diff --git a/docs/root.config.ts b/docs/root.config.ts index c8fee3302..81e28a214 100644 --- a/docs/root.config.ts +++ b/docs/root.config.ts @@ -4,10 +4,14 @@ import {defineConfig} from '@blinkk/root'; import {cmsPlugin} from '@blinkk/root-cms/plugin'; import {crowdinTranslationService} from './plugins/crowdin-translations.js'; import {deeplTranslationService} from './plugins/deepl-translations.js'; +import {emailNotificationsPlugin} from './plugins/email-notifications.js'; import {templatesPod} from './plugins/templates-pod.js'; const rootDir = new URL('.', import.meta.url).pathname; +// Email notifications are opt-in via env. See plugins/email-notifications.ts. +const emailNotifications = emailNotificationsPlugin(); + export default defineConfig({ domain: 'https://rootjs.dev', i18n: { @@ -65,6 +69,7 @@ export default defineConfig({ }), deeplTranslationService({apiKey: process.env.DEEPL_API_KEY}), ], + notifications: emailNotifications ? [emailNotifications] : [], checks: [ { id: 'custom/green-check', diff --git a/docs/scripts/test_email_notification.mjs b/docs/scripts/test_email_notification.mjs new file mode 100644 index 000000000..30ca00076 --- /dev/null +++ b/docs/scripts/test_email_notification.mjs @@ -0,0 +1,46 @@ +/** + * Sends a synthetic action through the CMS notification pipeline so the + * configured email notification plugin queues an email. Useful to verify + * the plugin end-to-end without performing a real publish or task action. + * + * Usage: + * pnpm --filter @private/docs exec node scripts/test_email_notification.mjs [--action=] [--task=] + */ +import {loadRootConfig} from '@blinkk/root/node'; +import {RootCMSClient} from '@blinkk/root-cms'; + +function parseArgs() { + const out = {action: 'doc.publish', metadata: {docId: 'Pages/test'}}; + for (const arg of process.argv.slice(2)) { + const [key, value] = arg.replace(/^--/, '').split('='); + if (!key) continue; + if (key === 'action') { + out.action = value || out.action; + } else if (key === 'task') { + out.action = 'tasks.comment.add'; + out.metadata = {taskId: value || '1', mentions: []}; + } else if (key === 'mention') { + out.metadata.mentions = (value || '').split(',').filter(Boolean); + } else if (key === 'docId') { + out.metadata.docId = value || out.metadata.docId; + } + } + return out; +} + +async function main() { + const {action, metadata} = parseArgs(); + const rootConfig = await loadRootConfig(process.cwd()); + const client = new RootCMSClient(rootConfig); + console.log(`logging action=${action} metadata=${JSON.stringify(metadata)}`); + await client.logAction(action, { + by: process.env.TEST_ACTOR || 'tester@example.com', + metadata, + }); + console.log('done. check Projects//Emails for the queued email doc.'); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +});