diff --git a/apps/gci/.gcloudignore b/apps/root-tools/.gcloudignore similarity index 100% rename from apps/gci/.gcloudignore rename to apps/root-tools/.gcloudignore diff --git a/apps/gci/app.yaml b/apps/root-tools/app.yaml similarity index 79% rename from apps/gci/app.yaml rename to apps/root-tools/app.yaml index 85a2e12dd..eafc66a14 100644 --- a/apps/gci/app.yaml +++ b/apps/root-tools/app.yaml @@ -1,5 +1,5 @@ runtime: go125 -service: gci +service: root-tools app_engine_apis: true handlers: diff --git a/apps/gci/deploy.sh b/apps/root-tools/deploy.sh similarity index 92% rename from apps/gci/deploy.sh rename to apps/root-tools/deploy.sh index 6e7d46d8f..bb7c75a93 100755 --- a/apps/gci/deploy.sh +++ b/apps/root-tools/deploy.sh @@ -1,6 +1,6 @@ #!/bin/sh # -# Deploys the Google Cloud Images microservice to Google App Engine. +# Deploys the Root.js tools microservice to Google App Engine. # # USAGE: # ./deploy.sh [options] diff --git a/apps/gci/go.mod b/apps/root-tools/go.mod similarity index 84% rename from apps/gci/go.mod rename to apps/root-tools/go.mod index 3e015d9e0..98074194b 100644 --- a/apps/gci/go.mod +++ b/apps/root-tools/go.mod @@ -1,8 +1,9 @@ -module github.com/blinkk/rootjs/apps/gci +module github.com/blinkk/rootjs/apps/tools go 1.20 require ( + cloud.google.com/go/firestore v1.9.0 cloud.google.com/go/storage v1.30.0 google.golang.org/appengine/v2 v2.0.2 ) @@ -12,6 +13,7 @@ require ( cloud.google.com/go/compute v1.18.0 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/iam v0.12.0 // indirect + cloud.google.com/go/longrunning v0.3.0 // indirect github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-cmp v0.5.9 // indirect @@ -21,8 +23,10 @@ require ( go.opencensus.io v0.24.0 // indirect golang.org/x/net v0.7.0 // indirect golang.org/x/oauth2 v0.5.0 // indirect + golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.5.0 // indirect golang.org/x/text v0.7.0 // indirect + golang.org/x/time v0.1.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/api v0.110.0 // indirect google.golang.org/appengine v1.6.7 // indirect diff --git a/apps/gci/go.sum b/apps/root-tools/go.sum similarity index 96% rename from apps/gci/go.sum rename to apps/root-tools/go.sum index 846a5b6f1..8fc15a840 100644 --- a/apps/gci/go.sum +++ b/apps/root-tools/go.sum @@ -5,9 +5,12 @@ cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9 cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA= +cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE= cloud.google.com/go/iam v0.12.0 h1:DRtTY29b75ciH6Ov1PHb4/iat2CLCvrOm40Q0a6DFpE= cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY= cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs= +cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc= cloud.google.com/go/storage v1.30.0 h1:g1yrbxAWOrvg/594228pETWkOi00MLTrOWfh56veU5o= cloud.google.com/go/storage v1.30.0/go.mod h1:xAVretHSROm1BQX4IIsoVgJqw0LqOyX+I/O2GzRAzdE= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -86,6 +89,8 @@ golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -101,6 +106,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/time v0.1.0 h1:xYY+Bajn2a7VBmTM5GikTmnK8ZuX8YgnQCqZpbBNtmA= +golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/apps/gci/main.go b/apps/root-tools/main.go similarity index 53% rename from apps/gci/main.go rename to apps/root-tools/main.go index 3ceb54d30..ceb4aef9d 100644 --- a/apps/gci/main.go +++ b/apps/root-tools/main.go @@ -1,16 +1,19 @@ package main import ( + "context" "encoding/json" "fmt" "log" "net/http" "strings" + "cloud.google.com/go/firestore" "cloud.google.com/go/storage" "google.golang.org/appengine/v2" "google.golang.org/appengine/v2/blobstore" "google.golang.org/appengine/v2/image" + "google.golang.org/appengine/v2/mail" ) type ServingURLData struct { @@ -21,7 +24,8 @@ type ServingURLData struct { func main() { http.HandleFunc("/_/serving_url", servingURLHandler) http.HandleFunc("/_/service_account", serviceAccountHandler) - log.Println("starting gci server") + http.HandleFunc("/_/send_emails", sendEmailsHandler) + log.Println("starting tools server") appengine.Main() } @@ -136,3 +140,114 @@ func serviceAccountHandler(w http.ResponseWriter, r *http.Request) { } w.Write(data) } + +type SendEmailsResult struct { + Success bool `json:"success"` + Sent int `json:"sent"` + Failed int `json:"failed"` + Errors []string `json:"errors,omitempty"` +} + +// sendEmailsHandler fetches pending emails from Firestore and sends them using +// the App Engine Mail API. It expects a ?projectId= query param to scope which +// project's email queue to process. +func sendEmailsHandler(w http.ResponseWriter, r *http.Request) { + ctx := appengine.NewContext(r) + w.Header().Set("Content-Type", "application/json") + + projectId := r.URL.Query().Get("projectId") + if projectId == "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"success": false, "error": "missing required param: projectId"}`) + return + } + + fsClient, err := firestore.NewClient(ctx, firestore.DetectProjectID) + if err != nil { + log.Printf("failed to create firestore client: %v", err) + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"success": false, "error": "failed to init firestore"}`) + return + } + defer fsClient.Close() + + result, err := processPendingEmails(ctx, fsClient, projectId) + if err != nil { + log.Printf("failed to process pending emails: %v", err) + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"success": false, "error": "failed to process emails"}`) + return + } + + data, err := json.Marshal(result) + if err != nil { + log.Printf("failed to serialize json: %v", err) + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `{"success": false}`) + return + } + w.Write(data) +} + +func processPendingEmails(ctx context.Context, fsClient *firestore.Client, projectId string) (*SendEmailsResult, error) { + colPath := fmt.Sprintf("Projects/%s/Emails", projectId) + query := fsClient.Collection(colPath). + Where("status", "==", "pending"). + OrderBy("createdAt", firestore.Asc). + Limit(100) + + docs, err := query.Documents(ctx).GetAll() + if err != nil { + return nil, fmt.Errorf("failed to query pending emails: %w", err) + } + + result := &SendEmailsResult{Success: true} + for _, doc := range docs { + data := doc.Data() + msg := &mail.Message{ + Sender: fmt.Sprintf("%v", data["from"]), + Subject: fmt.Sprintf("%v", data["subject"]), + Body: fmt.Sprintf("%v", data["body"]), + } + + // Parse recipients. + if toList, ok := data["to"].([]interface{}); ok { + for _, t := range toList { + msg.To = append(msg.To, fmt.Sprintf("%v", t)) + } + } + + // Include HTML body if present. + if htmlBody, ok := data["htmlBody"].(string); ok && htmlBody != "" { + msg.HTMLBody = htmlBody + } + + if err := mail.Send(ctx, msg); err != nil { + log.Printf("failed to send email %s: %v", doc.Ref.ID, err) + result.Failed++ + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", doc.Ref.ID, err)) + // Mark as failed in Firestore. + _, updateErr := doc.Ref.Update(ctx, []firestore.Update{ + {Path: "status", Value: "failed"}, + {Path: "error", Value: err.Error()}, + }) + if updateErr != nil { + log.Printf("failed to update email status to failed: %v", updateErr) + } + continue + } + + // Mark as sent in Firestore. + _, updateErr := doc.Ref.Update(ctx, []firestore.Update{ + {Path: "status", Value: "sent"}, + {Path: "sentAt", Value: firestore.ServerTimestamp}, + }) + if updateErr != nil { + log.Printf("failed to update email status to sent: %v", updateErr) + } + result.Sent++ + } + + log.Printf("processed %d emails for project %s: %d sent, %d failed", len(docs), projectId, result.Sent, result.Failed) + return result, nil +} diff --git a/apps/root-tools/tools b/apps/root-tools/tools new file mode 100755 index 000000000..2519681a7 Binary files /dev/null and b/apps/root-tools/tools differ diff --git a/packages/root-cms/core/api.ts b/packages/root-cms/core/api.ts index 1d4bc724d..0a7447576 100644 --- a/packages/root-cms/core/api.ts +++ b/packages/root-cms/core/api.ts @@ -12,6 +12,7 @@ import {type CMSCheck} from './checks.js'; import {RootCMSClient, parseDocId, unmarshalData} from './client.js'; import {runCronJobs} from './cron.js'; import {arrayToCsv, csvToArray} from './csv.js'; +import {type CMSEmailConfig} from './plugin.js'; import {type CMSTranslationService} from './translations.js'; type AppModule = typeof import('./app.js'); @@ -108,6 +109,8 @@ export interface ApiOptions { * may change from version to version as we add new features. */ translations?: CMSTranslationService[]; + /** Email notification config registered via the CMS plugin config. */ + email?: CMSEmailConfig; } /** @@ -811,4 +814,70 @@ export function api(server: Server, options: ApiOptions) { res.status(500).json({success: false, error: err.message || 'UNKNOWN'}); } } + + /** + * Sends pending emails using the registered email service. This endpoint + * processes queued emails from Firestore and sends them via the configured + * email service provider. + * + * ``` + * POST /cms/api/email.send + * ``` + */ + server.use('/cms/api/email.send', async (req: Request, res: Response) => { + if (req.method !== 'POST') { + res.status(400).json({success: false, error: 'BAD_REQUEST'}); + return; + } + if (!req.user?.email) { + res.status(401).json({success: false, error: 'UNAUTHORIZED'}); + return; + } + + const emailConfig = options.email; + const emailService = emailConfig?.service; + if (!emailService) { + res.status(400).json({ + success: false, + error: 'EMAIL_SERVICE_NOT_CONFIGURED', + }); + return; + } + + try { + const cmsClient = new RootCMSClient(req.rootConfig!); + const expiresAfterMs = emailConfig?.expiresAfterMs; + const pendingEmails = await cmsClient.listPendingEmails({ + expiresAfterMs, + }); + let sent = 0; + let failed = 0; + + for (const email of pendingEmails) { + const result = await emailService.sendEmail({ + to: email.to, + from: email.from, + subject: email.subject, + body: email.body, + htmlBody: email.htmlBody, + }); + if (result.success) { + await cmsClient.updateEmailStatus(email.id, 'sent'); + sent++; + } else { + await cmsClient.updateEmailStatus( + email.id, + 'failed', + result.error || 'unknown error' + ); + failed++; + } + } + + res.status(200).json({success: true, sent, failed}); + } catch (err: any) { + console.error(err.stack || err); + res.status(500).json({success: false, error: err.message || 'UNKNOWN'}); + } + }); } diff --git a/packages/root-cms/core/client.ts b/packages/root-cms/core/client.ts index 98adab814..f64eccb13 100644 --- a/packages/root-cms/core/client.ts +++ b/packages/root-cms/core/client.ts @@ -15,6 +15,9 @@ import {TranslationsManager} from './translations-manager.js'; import {validateFields} from './validation.js'; import {setValueAtPath} from './values.js'; +/** Default email expiration: 1 hour (in milliseconds). */ +const DEFAULT_EMAIL_EXPIRES_AFTER_MS = 60 * 60 * 1000; + export interface Doc { /** The id of the doc, e.g. "Pages/foo-bar". */ id: string; @@ -216,6 +219,48 @@ export interface ListActionsOptions { limit?: number; } +export type EmailStatus = 'pending' | 'sent' | 'failed'; + +export interface QueuedEmail { + /** Recipient email addresses. */ + to: string[]; + /** Sender email address. */ + from: string; + /** Email subject. */ + subject: string; + /** Plain text body. */ + body: string; + /** HTML body (optional). */ + htmlBody?: string; + /** Processing status. */ + status: EmailStatus; + /** Error message if status is "failed". */ + error?: string; + /** When the email was queued. */ + createdAt: Timestamp; + /** User or system that created the email. */ + createdBy: string; + /** When the email was sent. */ + sentAt?: Timestamp; + /** When the email expires and should no longer be sent. */ + expiresAt: Timestamp; + /** The CMS action that triggered this email. */ + action: string; + /** Additional context data. */ + metadata?: Record; +} + +export interface QueueEmailOptions { + to: string[]; + from: string; + subject: string; + body: string; + htmlBody?: string; + action: string; + createdBy?: string; + metadata?: Record; +} + export class RootCMSClient { readonly rootConfig: RootConfig; readonly cmsPlugin: CMSPlugin; @@ -1716,6 +1761,235 @@ export class RootCMSClient { console.error(err); } } + + // Queue email notification if the action matches a configured event. + if (cmsPluginConfig.email?.enabled) { + const events = cmsPluginConfig.email.events || []; + if (events.includes(action)) { + try { + await this.queueEmailForAction(data); + } catch (err) { + console.error(`failed to queue email for action "${action}":`, err); + } + } + } + } + + /** + * Returns the Firestore path for the Emails collection. + */ + private dbEmailsPath(): string { + return `Projects/${this.projectId}/Emails`; + } + + /** + * Resolves the email recipients from the plugin config. If the recipients + * list includes the sentinel value `"ADMINS"`, it resolves to all users + * with the ADMIN role in the project's ACL. + */ + private async resolveEmailRecipients(): Promise { + const emailConfig = this.cmsPlugin.getConfig().email; + if (!emailConfig?.recipients?.length) { + return []; + } + const resolved: string[] = []; + for (const recipient of emailConfig.recipients) { + if (recipient === 'ADMINS') { + const docRef = this.db.doc(`Projects/${this.projectId}`); + const snapshot = await docRef.get(); + const data = snapshot.data() || {}; + const acl: Record = data.roles || {}; + for (const [email, role] of Object.entries(acl)) { + if (role === 'ADMIN' && !email.startsWith('*@')) { + resolved.push(email); + } + } + } else { + resolved.push(recipient); + } + } + return [...new Set(resolved)]; + } + + /** + * Queues an email notification for a CMS action. + */ + private async queueEmailForAction(actionData: Action): Promise { + const emailConfig = this.cmsPlugin.getConfig().email; + if (!emailConfig?.sender) { + console.warn('email.sender is not configured, skipping email queue'); + return; + } + + const recipients = await this.resolveEmailRecipients(); + if (recipients.length === 0) { + console.warn('no email recipients configured, skipping email queue'); + return; + } + + const subject = `[${this.projectId}] ${actionData.action}`; + const metaStr = actionData.metadata + ? `\n\nMetadata:\n${JSON.stringify(actionData.metadata, null, 2)}` + : ''; + const body = `Action: ${actionData.action}\nBy: ${actionData.by || 'system'}\nTimestamp: ${actionData.timestamp.toDate().toISOString()}${metaStr}`; + + await this.queueEmail({ + to: recipients, + from: emailConfig.sender, + subject, + body, + action: actionData.action, + createdBy: actionData.by || 'system', + metadata: actionData.metadata, + }); + } + + /** + * Queues an email for sending. The email is stored in Firestore with a + * `pending` status and can be picked up by an external email-sending service. + */ + async queueEmail(options: QueueEmailOptions): Promise { + const cmsPluginConfig = this.cmsPlugin.getConfig(); + const emailConfig = cmsPluginConfig.email; + const expiresAfterMs = emailConfig?.expiresAfterMs ?? DEFAULT_EMAIL_EXPIRES_AFTER_MS; + const expiresAt = Timestamp.fromMillis(Date.now() + expiresAfterMs); + + const emailData: QueuedEmail = { + to: options.to, + from: options.from, + subject: options.subject, + body: options.body, + status: 'pending', + action: options.action, + createdAt: Timestamp.now(), + createdBy: options.createdBy || 'system', + expiresAt, + }; + if (options.htmlBody) { + emailData.htmlBody = options.htmlBody; + } + if (options.metadata) { + emailData.metadata = options.metadata; + } + + const colRef = this.db.collection(this.dbEmailsPath()); + const docRef = await colRef.add(emailData); + console.log(`queued email: ${docRef.id} (action: ${options.action})`); + + // If an email service is registered, attempt to send immediately. + const emailService = emailConfig?.service; + if (emailService) { + try { + const result = await emailService.sendEmail({ + to: options.to, + from: options.from, + subject: options.subject, + body: options.body, + htmlBody: options.htmlBody, + }); + if (result.success) { + await this.updateEmailStatus(docRef.id, 'sent'); + } else { + await this.updateEmailStatus( + docRef.id, + 'failed', + result.error || 'unknown error' + ); + } + } catch (err) { + console.error('email service failed to send:', err); + await this.updateEmailStatus(docRef.id, 'failed', String(err)); + } + } else if (emailConfig?.webhook?.url) { + // Fall back to webhook notification if no email service is registered. + this.fireEmailWebhook(emailConfig.webhook, { + id: docRef.id, + projectId: this.projectId, + ...emailData, + }); + } + + return docRef.id; + } + + /** + * Lists pending emails from the queue, filtering out expired emails. + * Emails that have passed their `expiresAt` timestamp are automatically + * marked as `failed` with an expiration error. + */ + async listPendingEmails(options?: { + limit?: number; + /** Override expiration window (ms). Uses the config default if unset. */ + expiresAfterMs?: number; + }): Promise<(QueuedEmail & {id: string})[]> { + const limit = options?.limit ?? 100; + const colRef = this.db.collection(this.dbEmailsPath()); + const snapshot = await colRef + .where('status', '==', 'pending') + .orderBy('createdAt', 'asc') + .limit(limit) + .get(); + + const now = Date.now(); + const result: (QueuedEmail & {id: string})[] = []; + + for (const doc of snapshot.docs) { + const data = doc.data() as QueuedEmail; + // Check expiration. If the email has an `expiresAt` field and it has + // passed, mark it as failed and skip it. + if (data.expiresAt && data.expiresAt.toMillis() <= now) { + await this.updateEmailStatus(doc.id, 'failed', 'expired'); + continue; + } + result.push({id: doc.id, ...data}); + } + + return result; + } + + /** + * Updates the status of a queued email. + */ + async updateEmailStatus( + emailId: string, + status: EmailStatus, + error?: string + ): Promise { + const docRef = this.db.doc(`${this.dbEmailsPath()}/${emailId}`); + const updateData: Record = {status}; + if (status === 'sent') { + updateData.sentAt = Timestamp.now(); + } + if (error) { + updateData.error = error; + } + await docRef.update(updateData); + } + + /** + * Sends a fire-and-forget webhook POST when a new email is queued. + */ + private fireEmailWebhook( + webhook: {url: string; secret?: string}, + payload: Record + ): void { + const body = JSON.stringify(payload); + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (webhook.secret) { + const hmac = crypto.createHmac('sha256', webhook.secret); + hmac.update(body); + headers['X-Root-Signature'] = hmac.digest('hex'); + } + // Fire and forget. + fetch(webhook.url, { + method: 'POST', + headers, + body, + }).catch((err) => { + console.error('failed to send email webhook:', err); + }); } /** diff --git a/packages/root-cms/core/email-service.ts b/packages/root-cms/core/email-service.ts new file mode 100644 index 000000000..504cccdde --- /dev/null +++ b/packages/root-cms/core/email-service.ts @@ -0,0 +1,94 @@ +import crypto from 'node:crypto'; +import { + type CMSEmailServiceProvider, + type SendEmailOptions, + type SendEmailResult, +} from './services.js'; + +export interface RootEmailServiceOptions { + /** + * The URL of the root-tools service endpoint for sending emails. + * + * Example: `https://root-tools.example.com/_/send_emails` + */ + webhookUrl: string; + /** + * The Firebase/GCP project ID. Used to scope email queries to the + * correct Firestore project namespace. + */ + projectId: string; + /** Optional shared secret for HMAC-SHA256 request signing. */ + secret?: string; +} + +/** + * Email service that sends emails by calling the Root.js tools App Engine + * service. The tools service processes pending emails from Firestore and + * sends them using the App Engine Mail API. + * + * Example: + * ```ts + * import {rootEmailService} from '@blinkk/root-cms/plugin'; + * + * cmsPlugin({ + * email: { + * enabled: true, + * sender: 'noreply@example.com', + * recipients: ['admin@example.com'], + * events: ['doc.publish'], + * service: rootEmailService({ + * webhookUrl: 'https://root-tools.example.com/_/send_emails', + * projectId: 'my-project', + * }), + * }, + * }); + * ``` + */ +export function rootEmailService( + options: RootEmailServiceOptions +): CMSEmailServiceProvider { + return { + async sendEmail(emailOptions: SendEmailOptions): Promise { + try { + const url = new URL(options.webhookUrl); + url.searchParams.set('projectId', options.projectId); + + const body = JSON.stringify({ + to: emailOptions.to, + from: emailOptions.from, + subject: emailOptions.subject, + body: emailOptions.body, + htmlBody: emailOptions.htmlBody, + }); + + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (options.secret) { + const hmac = crypto.createHmac('sha256', options.secret); + hmac.update(body); + headers['X-Root-Signature'] = hmac.digest('hex'); + } + + const res = await fetch(url.toString(), { + method: 'POST', + headers, + body, + }); + + if (!res.ok) { + const text = await res.text(); + return {success: false, error: `HTTP ${res.status}: ${text}`}; + } + + const data = await res.json(); + if (data.success) { + return {success: true}; + } + return {success: false, error: data.error || 'unknown error'}; + } catch (err) { + return {success: false, error: String(err)}; + } + }, + }; +} diff --git a/packages/root-cms/core/plugin.ts b/packages/root-cms/core/plugin.ts index 844d9ce66..2d40ea9e6 100644 --- a/packages/root-cms/core/plugin.ts +++ b/packages/root-cms/core/plugin.ts @@ -27,9 +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 CMSTranslationService} from './translations.js'; import {Action, RootCMSClient} from './client.js'; +import {type CMSEmailServiceProvider} from './services.js'; import {sse, SSEBroadcastFn} from './sse.js'; +import {type CMSTranslationService} from './translations.js'; export type { CMSCheck, @@ -39,6 +40,13 @@ export type { } from './checks.js'; export {translationsCheck} from './checks-translations.js'; export type {TranslationsCheckOptions} from './checks-translations.js'; +export {rootEmailService} from './email-service.js'; +export type {RootEmailServiceOptions} from './email-service.js'; +export type { + CMSEmailServiceProvider, + SendEmailOptions, + SendEmailResult, +} from './services.js'; export type { CMSTranslationService, TranslationExportResult, @@ -132,6 +140,74 @@ export interface CMSSidebarTool { externalUrl?: string; } +export interface CMSEmailConfig { + /** Whether email notifications are enabled. */ + enabled?: boolean; + /** + * The sender email address used in the "From" field. Must be an authorized + * sender for the email service being used (e.g. App Engine Mail API requires + * the sender to be an app admin or the app's default address). + */ + sender?: string; + /** + * List of recipient email addresses. Use the special value `"ADMINS"` to + * auto-resolve recipients from the project's ACL (users with the ADMIN role). + */ + recipients?: string[]; + /** + * List of CMS action names that should trigger email notifications. + * Supported events include: + * - `doc.publish` — when a document is published + * - `doc.scheduled_publish` — when a scheduled doc is auto-published + * - `doc.unpublish` — when a document is unpublished + */ + events?: string[]; + /** + * Optional webhook configuration. When set, an HTTP POST request is sent + * to the specified URL whenever a new email is queued. This can be used + * to trigger an external email-sending service (e.g. the Root.js tools + * App Engine service). + */ + webhook?: { + /** The URL to send the HTTP POST to. */ + url: string; + /** Shared secret for generating an HMAC-SHA256 signature header. */ + secret?: string; + }; + /** + * An email service provider used to send queued emails. When set, emails + * are sent immediately after being queued. If not set, emails remain in + * `pending` status until processed by an external service (e.g. via the + * webhook). + * + * Example: + * ```ts + * import {rootEmailService} from '@blinkk/root-cms/plugin'; + * + * cmsPlugin({ + * email: { + * enabled: true, + * sender: 'noreply@example.com', + * recipients: ['admin@example.com'], + * events: ['doc.publish'], + * service: rootEmailService({ + * webhookUrl: 'https://tools.example.com/_/send_emails', + * projectId: 'my-project', + * }), + * }, + * }); + * ``` + */ + service?: CMSEmailServiceProvider; + /** + * How long a pending email remains eligible for sending, in milliseconds. + * Emails older than this are skipped to avoid sending a large backlog if + * the email runner hasn't been invoked in a while. Defaults to `3600000` + * (1 hour). + */ + expiresAfterMs?: number; +} + export type CMSPluginOptions = { /** * The ID of the project. Data will be stored under the namespace @@ -247,6 +323,27 @@ export type CMSPluginOptions = { */ onAction?: (action: Action) => any; + /** + * Email notification settings. When configured, the CMS will automatically + * queue email notifications in Firestore when specified actions occur. + * Emails are stored in a `Projects/{id}/Emails` collection and can be sent + * by any external service (e.g. a cron job hitting the tools App Engine + * service). + * + * Example: + * ```ts + * cmsPlugin({ + * email: { + * enabled: true, + * sender: 'noreply@example.com', + * recipients: ['admin@example.com'], + * events: ['doc.publish', 'doc.scheduled_publish', 'doc.unpublish'], + * }, + * }); + * ``` + */ + email?: CMSEmailConfig; + /** * Experimental config options. Note: these are subject to change at any time. */ @@ -760,6 +857,7 @@ export function cmsPlugin(options: CMSPluginOptions): CMSPlugin { getRenderer, checks: options.checks, translations: options.translations, + email: options.email, }); // Render the CMS SPA. diff --git a/packages/root-cms/core/services.ts b/packages/root-cms/core/services.ts new file mode 100644 index 000000000..e68b1936b --- /dev/null +++ b/packages/root-cms/core/services.ts @@ -0,0 +1,41 @@ +/** Options for sending an email. */ +export interface SendEmailOptions { + /** Recipient email addresses. */ + to: string[]; + /** Sender email address. */ + from: string; + /** Email subject. */ + subject: string; + /** Plain text body. */ + body: string; + /** HTML body (optional). */ + htmlBody?: string; +} + +/** Result returned after sending an email. */ +export interface SendEmailResult { + /** Whether the email was sent successfully. */ + success: boolean; + /** Error message if the send failed. */ + error?: string; +} + +/** + * Interface for an email service provider that can send emails. Implement + * this interface to integrate with any email provider (e.g. App Engine Mail + * API, SendGrid, Mailgun, etc.). + * + * Example: + * ```ts + * const myEmailService: CMSEmailServiceProvider = { + * async sendEmail(options) { + * // Send the email using your provider. + * return {success: true}; + * }, + * }; + * ``` + */ +export interface CMSEmailServiceProvider { + /** Sends an email. */ + sendEmail(options: SendEmailOptions): Promise; +}