Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/email-notification-plugin.md
Original file line number Diff line number Diff line change
@@ -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 `@<email>` 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.
13 changes: 13 additions & 0 deletions apps/root-services/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
100 changes: 100 additions & 0 deletions docs/plugins/email-notifications.ts
Original file line number Diff line number Diff line change
@@ -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:<NAME>` 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<RootEmailNotificationPluginOptions> = {}
): 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,
});
}
5 changes: 5 additions & 0 deletions docs/root.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -65,6 +69,7 @@ export default defineConfig({
}),
deeplTranslationService({apiKey: process.env.DEEPL_API_KEY}),
],
notifications: emailNotifications ? [emailNotifications] : [],
checks: [
{
id: 'custom/green-check',
Expand Down
46 changes: 46 additions & 0 deletions docs/scripts/test_email_notification.mjs
Original file line number Diff line number Diff line change
@@ -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=<name>] [--task=<id>]
*/
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/<id>/Emails for the queued email doc.');
}

main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
Loading