diff --git a/firestore-bigquery-export/CHANGELOG.md b/firestore-bigquery-export/CHANGELOG.md index 4b29aab18..653f7b117 100644 --- a/firestore-bigquery-export/CHANGELOG.md +++ b/firestore-bigquery-export/CHANGELOG.md @@ -1,3 +1,7 @@ +## Version 0.2.4 + +feat: Add bigquery dataset locations and remove duplicates + ## Version 0.2.3 fix: pass full document resource name to bigquery diff --git a/firestore-bigquery-export/extension.yaml b/firestore-bigquery-export/extension.yaml index 8d4b1baa2..744895f44 100644 --- a/firestore-bigquery-export/extension.yaml +++ b/firestore-bigquery-export/extension.yaml @@ -13,7 +13,7 @@ # limitations under the License. name: firestore-bigquery-export -version: 0.2.3 +version: 0.2.4 specVersion: v1beta displayName: Stream Firestore to BigQuery @@ -163,18 +163,50 @@ params: value: asia-northeast2 - label: Seoul (asia-northeast3) value: asia-northeast3 - - label: Singapore (asia-southeast1) - value: asia-southeast1 - label: Sydney (australia-southeast1) value: australia-southeast1 - - label: Taiwan (asia-east1) - value: asia-east1 - label: Tokyo (asia-northeast1) value: asia-northeast1 - label: United States (multi-regional) value: us - label: Europe (multi-regional) value: eu + - label: Johannesburg (africa-south1) + value: africa-south1 + - label: Tel Aviv (me-west1) + value: me-west1 + - label: Doha (me-central1) + value: me-central1 + - label: Dammam (me-central2) + value: me-central2 + - label: Zürich (europe-west6) + value: europe-west6 + - label: Turin (europe-west12) + value: europe-west12 + - label: Stockholm (europe-north2) + value: europe-north2 + - label: Paris (europe-west9) + value: europe-west9 + - label: Milan (europe-west8) + value: europe-west8 + - label: Madrid (europe-southwest1) + value: europe-southwest1 + - label: Berlin (europe-west10) + value: europe-west10 + - label: Melbourne (australia-southeast2) + value: australia-southeast2 + - label: Delhi (asia-south2) + value: asia-south2 + - label: Toronto (northamerica-northeast2) + value: northamerica-northeast2 + - label: Santiago (southamerica-west1) + value: southamerica-west1 + - label: Mexico (northamerica-south1) + value: northamerica-south1 + - label: Dallas (us-south1) + value: us-south1 + - label: Columbus, Ohio (us-east5) + value: us-east5 default: us required: true immutable: true diff --git a/firestore-bigquery-export/firestore-bigquery-change-tracker/package-lock.json b/firestore-bigquery-export/firestore-bigquery-change-tracker/package-lock.json index 391105aa9..1b81fd937 100644 --- a/firestore-bigquery-export/firestore-bigquery-change-tracker/package-lock.json +++ b/firestore-bigquery-export/firestore-bigquery-change-tracker/package-lock.json @@ -1,12 +1,12 @@ { "name": "@firebaseextensions/firestore-bigquery-change-tracker", - "version": "1.1.40", + "version": "1.1.41", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@firebaseextensions/firestore-bigquery-change-tracker", - "version": "1.1.40", + "version": "1.1.41", "license": "Apache-2.0", "dependencies": { "@google-cloud/bigquery": "^7.6.0", diff --git a/firestore-bigquery-export/scripts/import/src/index.ts b/firestore-bigquery-export/scripts/import/src/index.ts index c7da5dd7e..0b9b3d439 100644 --- a/firestore-bigquery-export/scripts/import/src/index.ts +++ b/firestore-bigquery-export/scripts/import/src/index.ts @@ -105,7 +105,7 @@ const run = async (): Promise => { cursor = await firebase.firestore().doc(cursorDocumentId).get(); logs.resumingImport(config, cursorDocumentId); } - const totalRowsImported = runSingleThread(dataSink, config, cursor); + const totalRowsImported = await runSingleThread(dataSink, config, cursor); try { await unlink(cursorPositionFile); } catch (e) { diff --git a/firestore-bigquery-export/scripts/import/src/run-single-thread.ts b/firestore-bigquery-export/scripts/import/src/run-single-thread.ts index 4d23a811f..87b28e02c 100644 --- a/firestore-bigquery-export/scripts/import/src/run-single-thread.ts +++ b/firestore-bigquery-export/scripts/import/src/run-single-thread.ts @@ -47,6 +47,45 @@ export function getQuery( return query; } +async function verifyCollectionExists(config: CliConfig): Promise { + const { sourceCollectionPath, queryCollectionGroup } = config; + + try { + if (queryCollectionGroup) { + const sourceCollectionPathParts = sourceCollectionPath.split("/"); + const collectionName = + sourceCollectionPathParts[sourceCollectionPathParts.length - 1]; + const snapshot = await firebase + .firestore() + .collectionGroup(collectionName) + .limit(1) + .get(); + if (snapshot.empty) { + throw new Error( + `No documents found in collection group: ${collectionName}` + ); + } + } else { + const snapshot = await firebase + .firestore() + .collection(sourceCollectionPath) + .limit(1) + .get(); + if (snapshot.empty) { + throw new Error( + `Collection does not exist or is empty: ${sourceCollectionPath}` + ); + } + } + } catch (error) { + if (error instanceof Error) { + error.message = `Failed to access collection: ${error.message}`; + throw error; + } + throw error; + } +} + export async function runSingleThread( dataSink: FirestoreBigQueryEventHistoryTracker, config: CliConfig, @@ -56,6 +95,8 @@ export async function runSingleThread( ) { let totalRowsImported = 0; + await verifyCollectionExists(config); + await initializeFailedBatchOutput(config.failedBatchOutput); while (true) { diff --git a/firestore-send-email/CHANGELOG.md b/firestore-send-email/CHANGELOG.md index a6006e20d..dc0c85e4b 100644 --- a/firestore-send-email/CHANGELOG.md +++ b/firestore-send-email/CHANGELOG.md @@ -1,3 +1,11 @@ +## Version 0.2.1 + +fix: return info on sendgrid messages + +feat: include sendgridQueueId if using that provider + +fix: improve validation of triggering firestore objects + ## Version 0.2.0 feat: use v2 firestore trigger diff --git a/firestore-send-email/POSTINSTALL.md b/firestore-send-email/POSTINSTALL.md index 67abc8f23..1c3d74fc9 100644 --- a/firestore-send-email/POSTINSTALL.md +++ b/firestore-send-email/POSTINSTALL.md @@ -44,7 +44,7 @@ See the [official documentation](https://firebase.google.com/docs/extensions/off When using SendGrid (`SMTP_CONNECTION_URI` includes `sendgrid.net`), you can assign categories to your emails. -## Example JSON with Categories: +##### Example JSON with Categories: ```json { "to": ["example@example.com"], @@ -61,6 +61,30 @@ Add this document to the Firestore mail collection to send categorized emails. For more details, see the [SendGrid Categories documentation](https://docs.sendgrid.com/ui/sending-email/categories). +#### Understanding SendGrid Email IDs + +When an email is sent successfully, the extension tracks two different IDs in the delivery information: + +- **Queue ID**: This is SendGrid's internal queue identifier (from the `x-message-id` header). It's useful for tracking the email within SendGrid's system. +- **Message ID**: This is the RFC-2822 Message-ID header, which is a standard email identifier used across email systems. + +You can find both IDs in the `delivery.info` field of your email document after successful delivery: + +```json +{ + "delivery": { + "info": { + "messageId": "", + "sendgridQueueId": "sendgrid-queue-id", + "accepted": ["recipient@example.com"], + "rejected": [], + "pending": [], + "response": "status=202" + } + } +} +``` + ### Automatic Deletion of Email Documents To use Firestore's TTL feature for automatic deletion of expired email documents, the extension provides several configuration parameters. diff --git a/firestore-send-email/extension.yaml b/firestore-send-email/extension.yaml index a52eb2f42..591cef265 100644 --- a/firestore-send-email/extension.yaml +++ b/firestore-send-email/extension.yaml @@ -13,7 +13,7 @@ # limitations under the License. name: firestore-send-email -version: 0.2.0 +version: 0.2.1 specVersion: v1beta displayName: Trigger Email from Firestore diff --git a/firestore-send-email/functions/__tests__/nodemailer-sendgrid/index.test.ts b/firestore-send-email/functions/__tests__/nodemailer-sendgrid/index.test.ts new file mode 100644 index 000000000..3ec2c7753 --- /dev/null +++ b/firestore-send-email/functions/__tests__/nodemailer-sendgrid/index.test.ts @@ -0,0 +1,453 @@ +import * as sgMail from "@sendgrid/mail"; +import { SendGridTransport } from "../../src/nodemailer-sendgrid"; +import { + SendGridTransportOptions, + MailSource, + Address, + AttachmentEntry, + IcalEvent, +} from "../../src/nodemailer-sendgrid/types"; + +jest.mock("@sendgrid/mail", () => ({ + setApiKey: jest.fn(), + send: jest.fn().mockResolvedValue([ + { + headers: { + "x-message-id": "test-message-id", + }, + statusCode: 202, + }, + {}, + ]), +})); + +describe("SendGridTransport", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("constructor: sets API key when provided", () => { + new SendGridTransport({ apiKey: "API-KEY-123" }); + expect(sgMail.setApiKey as jest.Mock).toHaveBeenCalledWith("API-KEY-123"); + }); + + test("constructor: does not call setApiKey when no apiKey option", () => { + new SendGridTransport({}); + expect(sgMail.setApiKey as jest.Mock).not.toHaveBeenCalled(); + }); + + test("send: callback with error if normalize errors", async () => { + const transport = new SendGridTransport({ apiKey: "X" }); + const fakeErr = new Error("normalize failed"); + const fakeMail: Partial = { + normalize: (cb) => cb(fakeErr, {} as any), + }; + + const cb = jest.fn(); + transport.send(fakeMail as MailSource, cb); + + // allow normalize→callback to run + await new Promise((r) => setImmediate(r)); + + expect(cb).toHaveBeenCalledWith(fakeErr); + expect(sgMail.send as jest.Mock).not.toHaveBeenCalled(); + }); + + test("send: basic subject/text/html mapping", async () => { + const transport = new SendGridTransport(); + const source = { subject: "S", text: "T", html: "

H

" }; + const fakeMail: any = { normalize: (cb: any) => cb(null, source) }; + + const cb = jest.fn(); + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + expect(sgMail.send).toHaveBeenCalledWith({ + subject: "S", + text: "T", + html: "

H

", + }); + expect(cb).toHaveBeenCalledWith(null, { + messageId: null, + queueId: "test-message-id", + accepted: [], + rejected: [], + pending: [], + response: "status=202", + }); + }); + + test("send: maps from and replyTo", async () => { + const transport = new SendGridTransport(); + const addr: Address = { name: "Alice", address: "a@x.com" }; + const source = { from: addr, replyTo: [addr] }; + const fakeMail: any = { normalize: (cb) => cb(null, source) }; + + const cb = jest.fn(); + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + const sentMsg = (sgMail.send as jest.Mock).mock.calls[0][0]; + expect(sentMsg.from).toEqual({ name: "Alice", email: "a@x.com" }); + expect(sentMsg.replyTo).toEqual({ name: "Alice", email: "a@x.com" }); + expect(cb).toHaveBeenCalledWith(null, { + messageId: null, + queueId: "test-message-id", + accepted: [], + rejected: [], + pending: [], + response: "status=202", + }); + }); + + test("send: maps to, cc, bcc arrays", async () => { + const transport = new SendGridTransport(); + const a1: Address = { name: "B", address: "b@x" }; + const a2: Address = { name: "C", address: "c@x" }; + const source = { to: [a1], cc: a2, bcc: [a1, a2] }; + const fakeMail: any = { normalize: (cb) => cb(null, source) }; + + const cb = jest.fn(); + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + const sent = (sgMail.send as jest.Mock).mock.calls[0][0]; + expect(sent.to).toEqual([{ name: "B", email: "b@x" }]); + expect(sent.cc).toEqual([{ name: "C", email: "c@x" }]); + expect(sent.bcc).toEqual([ + { name: "B", email: "b@x" }, + { name: "C", email: "c@x" }, + ]); + expect(cb).toHaveBeenCalledWith(null, { + messageId: null, + queueId: "test-message-id", + accepted: ["b@x", "c@x"], + rejected: [], + pending: [], + response: "status=202", + }); + }); + + test("send: attachments with inline and normal dispositions", async () => { + const transport = new SendGridTransport(); + const atchs: AttachmentEntry[] = [ + { content: "foo", filename: "f.txt", contentType: "text/plain" }, + { + content: "img", + filename: "i.png", + contentType: "image/png", + cid: "cid123", + }, + ]; + const source = { attachments: atchs }; + const fakeMail: any = { normalize: (cb) => cb(null, source) }; + + const cb = jest.fn(); + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + const sentAtt = (sgMail.send as jest.Mock).mock.calls[0][0].attachments; + expect(sentAtt).toHaveLength(2); + // first: normal + expect(sentAtt[0]).toMatchObject({ + content: "foo", + filename: "f.txt", + type: "text/plain", + disposition: "attachment", + }); + // second: inline + expect(sentAtt[1]).toMatchObject({ + content: "img", + filename: "i.png", + type: "image/png", + disposition: "inline", + content_id: "cid123", + }); + expect(cb).toHaveBeenCalledWith(null, { + messageId: null, + queueId: "test-message-id", + accepted: [], + rejected: [], + pending: [], + response: "status=202", + }); + }); + + test("send: alternatives → content", async () => { + const transport = new SendGridTransport(); + const alts = [{ content: "alt", contentType: "text/alt" }]; + const source = { alternatives: alts }; + const fakeMail: any = { normalize: (cb) => cb(null, source) }; + + const cb = jest.fn(); + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + const sentContent = (sgMail.send as jest.Mock).mock.calls[0][0].content; + expect(sentContent).toEqual([{ type: "text/alt", value: "alt" }]); + expect(cb).toHaveBeenCalledWith(null, { + messageId: null, + queueId: "test-message-id", + accepted: [], + rejected: [], + pending: [], + response: "status=202", + }); + }); + + test("send: icalEvent as attachment", async () => { + const transport = new SendGridTransport(); + const ev: IcalEvent = { content: "ics", filename: "evt.ics" }; + const source = { icalEvent: ev }; + const fakeMail: any = { normalize: (cb) => cb(null, source) }; + + const cb = jest.fn(); + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + const sentAtt = (sgMail.send as jest.Mock).mock.calls[0][0].attachments![0]; + expect(sentAtt).toMatchObject({ + content: "ics", + filename: "evt.ics", + type: "application/ics", + disposition: "attachment", + }); + expect(cb).toHaveBeenCalledWith(null, { + messageId: null, + queueId: "test-message-id", + accepted: [], + rejected: [], + pending: [], + response: "status=202", + }); + }); + + test("send: watchHtml → content", async () => { + const transport = new SendGridTransport(); + const source = { watchHtml: "" }; + const fakeMail: any = { normalize: (cb) => cb(null, source) }; + + const cb = jest.fn(); + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + const content = (sgMail.send as jest.Mock).mock.calls[0][0].content; + expect(content).toEqual([{ type: "text/watch-html", value: "" }]); + expect(cb).toHaveBeenCalledWith(null, { + messageId: null, + queueId: "test-message-id", + accepted: [], + rejected: [], + pending: [], + response: "status=202", + }); + }); + + test("send: normalizedHeaders & messageId → headers", async () => { + const transport = new SendGridTransport(); + const source = { + normalizedHeaders: { "X-Custom": "val" }, + messageId: "msg-123", + }; + const fakeMail: any = { normalize: (cb) => cb(null, source) }; + + const cb = jest.fn(); + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + const headers = (sgMail.send as jest.Mock).mock.calls[0][0].headers; + expect(headers).toMatchObject({ + "X-Custom": "val", + "message-id": "msg-123", + }); + expect(cb).toHaveBeenCalledWith(null, { + messageId: "msg-123", + queueId: "test-message-id", + accepted: [], + rejected: [], + pending: [], + response: "status=202", + }); + }); + + test("send: merges text/html into content array when alternatives present", async () => { + const transport = new SendGridTransport(); + const source = { + text: "TXT", + html: "", + alternatives: [{ content: "alt1", contentType: "type1" }], + }; + const fakeMail: any = { normalize: (cb) => cb(null, source) }; + + const cb = jest.fn(); + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + const content = (sgMail.send as jest.Mock).mock.calls[0][0].content; + expect(content).toEqual([ + { type: "text/html", value: "" }, + { type: "text/plain", value: "TXT" }, + { type: "type1", value: "alt1" }, + ]); + expect(cb).toHaveBeenCalledWith(null, { + messageId: null, + queueId: "test-message-id", + accepted: [], + rejected: [], + pending: [], + response: "status=202", + }); + }); + + test("send: callback with error if sgMail.send rejects", async () => { + const transport = new SendGridTransport(); + const source = { subject: "Hi" }; + const fakeMail: any = { normalize: (cb) => cb(null, source) }; + const sendErr = new Error("send failed"); + (sgMail.send as jest.Mock).mockRejectedValueOnce(sendErr); + + const cb = jest.fn(); + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + expect(cb).toHaveBeenCalledWith(sendErr); + }); + + test("send: forwards categories array", async () => { + const transport = new SendGridTransport({ apiKey: "KEY" }); + const fakeMail: any = { + normalize: (cb: any) => + cb(null, { + from: { address: "a@x.com" }, + to: [{ address: "b@x.com" }], + subject: "Category test", + categories: ["alpha", "beta", "gamma"], + }), + }; + const cb = jest.fn(); + + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + const sent = (sgMail.send as jest.Mock).mock.calls[0][0]; + expect(sent.categories).toEqual(["alpha", "beta", "gamma"]); + expect(cb).toHaveBeenCalledWith(null, { + messageId: null, + queueId: "test-message-id", + accepted: ["b@x.com"], + rejected: [], + pending: [], + response: "status=202", + }); + }); + + test("send: forwards templateId & dynamicTemplateData", async () => { + const transport = new SendGridTransport({ apiKey: "KEY" }); + const fakeMail: any = { + normalize: (cb: any) => + cb(null, { + from: { address: "from@ex.com" }, + to: [{ address: "to@ex.com" }], + subject: "Template test", + templateId: "d-1234567890abcdef", + dynamicTemplateData: { name: "Jacob", count: 42 }, + }), + }; + const cb = jest.fn(); + + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + const sent = (sgMail.send as jest.Mock).mock.calls[0][0]; + expect(sent.templateId).toBe("d-1234567890abcdef"); + expect(sent.dynamicTemplateData).toMatchObject({ + name: "Jacob", + count: 42, + }); + expect(cb).toHaveBeenCalledWith(null, { + messageId: null, + queueId: "test-message-id", + accepted: ["to@ex.com"], + rejected: [], + pending: [], + response: "status=202", + }); + }); + + test("send: forwards mailSettings object", async () => { + const transport = new SendGridTransport({ apiKey: "KEY" }); + const fakeMail: any = { + normalize: (cb: any) => + cb(null, { + from: { address: "a@x.com" }, + to: [{ address: "b@x.com" }], + subject: "MailSettings test", + mailSettings: { + sandboxMode: { enable: true }, + personalization: { enable: false }, + }, + }), + }; + const cb = jest.fn(); + + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + const sent = (sgMail.send as jest.Mock).mock.calls[0][0]; + expect(sent.mailSettings).toMatchObject({ + sandboxMode: { enable: true }, + personalization: { enable: false }, + }); + expect(cb).toHaveBeenCalledWith(null, { + messageId: null, + queueId: "test-message-id", + accepted: ["b@x.com"], + rejected: [], + pending: [], + response: "status=202", + }); + }); + + test("send: deduplicates and normalizes email addresses", async () => { + const transport = new SendGridTransport(); + const source = { + to: [ + { address: "User@example.com" }, + { address: "user@example.com" }, // Duplicate with different case + ], + cc: [ + { address: "user@example.com" }, // Duplicate + { address: "other@example.com" }, + ], + bcc: [ + { address: "user@example.com" }, // Duplicate + { address: "ANOTHER@example.com" }, + ], + }; + const fakeMail: any = { normalize: (cb) => cb(null, source) }; + + const cb = jest.fn(); + transport.send(fakeMail, cb); + await new Promise((r) => setImmediate(r)); + + // Verify the message was sent with all recipients + const sent = (sgMail.send as jest.Mock).mock.calls[0][0]; + expect(sent.to).toHaveLength(2); + expect(sent.cc).toHaveLength(2); + expect(sent.bcc).toHaveLength(2); + + // Verify accepted array has deduplicated, lowercase emails + expect(cb).toHaveBeenCalledWith(null, { + messageId: null, + queueId: "test-message-id", + accepted: [ + "user@example.com", + "other@example.com", + "another@example.com", + ], + rejected: [], + pending: [], + response: "status=202", + }); + }); +}); diff --git a/firestore-send-email/functions/__tests__/validation.test.ts b/firestore-send-email/functions/__tests__/validation.test.ts new file mode 100644 index 000000000..d18390a19 --- /dev/null +++ b/firestore-send-email/functions/__tests__/validation.test.ts @@ -0,0 +1,199 @@ +import { validatePayload, ValidationError } from "../src/validation"; + +describe("validatePayload", () => { + // Test valid standard message payload + it("should validate a standard message payload", () => { + const validPayload = { + to: "test@example.com", + message: { + subject: "Test Subject", + text: "Test message", + }, + }; + expect(() => validatePayload(validPayload)).not.toThrow(); + }); + + // Test valid HTML message + it("should validate a message with HTML content", () => { + const validPayload = { + to: "test@example.com", + message: { + subject: "Test Subject", + html: "

Test message

", + }, + }; + expect(() => validatePayload(validPayload)).not.toThrow(); + }); + + // Test valid SendGrid template + it("should validate a SendGrid template payload", () => { + const validPayload = { + to: "test@example.com", + sendGrid: { + templateId: "d-template-id", + dynamicTemplateData: { + name: "Test User", + }, + }, + }; + expect(() => validatePayload(validPayload)).not.toThrow(); + }); + + // Test valid custom template + it("should validate a custom template payload", () => { + const validPayload = { + to: "test@example.com", + template: { + name: "welcome-email", + data: { + name: "Test User", + }, + }, + }; + expect(() => validatePayload(validPayload)).not.toThrow(); + }); + + // Test invalid message (missing text/html) + it("should throw ValidationError for message without text or html", () => { + const invalidPayload = { + to: "test@example.com", + message: { + subject: "Test Subject", + }, + }; + expect(() => validatePayload(invalidPayload)).toThrow(ValidationError); + expect(() => validatePayload(invalidPayload)).toThrow( + "Invalid email configuration: At least one of 'text' or 'html' must be provided in message" + ); + }); + + // Test invalid SendGrid template (missing templateId) + it("should throw ValidationError for SendGrid template without templateId", () => { + const invalidPayload = { + to: "test@example.com", + sendGrid: { + dynamicTemplateData: { + name: "Test User", + }, + }, + }; + expect(() => validatePayload(invalidPayload)).toThrow(ValidationError); + expect(() => validatePayload(invalidPayload)).toThrow( + "Invalid email configuration: Field 'sendGrid.templateId' must be a string" + ); + }); + + // Test invalid custom template (missing name) + it("should throw ValidationError for custom template without name", () => { + const invalidPayload = { + to: "test@example.com", + template: { + data: { + name: "Test User", + }, + }, + }; + expect(() => validatePayload(invalidPayload)).toThrow(ValidationError); + expect(() => validatePayload(invalidPayload)).toThrow( + "Invalid email configuration: Field 'template.name' must be a string" + ); + }); + + // Test missing recipients + it("should throw ValidationError when no recipients are provided", () => { + const invalidPayload = { + message: { + subject: "Test Subject", + text: "Test message", + }, + }; + expect(() => validatePayload(invalidPayload)).toThrow(ValidationError); + expect(() => validatePayload(invalidPayload)).toThrow( + "Email must have at least one recipient" + ); + }); + + // Test array of recipients + it("should validate multiple recipients", () => { + const validPayload = { + to: ["test1@example.com", "test2@example.com"], + cc: ["cc1@example.com"], + bcc: ["bcc1@example.com"], + message: { + subject: "Test Subject", + text: "Test message", + }, + }; + expect(() => validatePayload(validPayload)).not.toThrow(); + }); + + // Test UID-based recipients + it("should validate UID-based recipients", () => { + const validPayload = { + toUids: ["user1", "user2"], + ccUids: ["user3"], + bccUids: ["user4"], + message: { + subject: "Test Subject", + text: "Test message", + }, + }; + expect(() => validatePayload(validPayload)).not.toThrow(); + }); + + // Test optional fields + it("should validate payload with optional fields", () => { + const validPayload = { + to: "test@example.com", + from: "sender@example.com", + replyTo: "reply@example.com", + categories: ["category1", "category2"], + message: { + subject: "Test Subject", + text: "Test message", + attachments: [], + }, + }; + expect(() => validatePayload(validPayload)).not.toThrow(); + }); + + // Test invalid field types + it("should throw ValidationError for invalid field types", () => { + const invalidPayload = { + to: 123, // should be string or array + message: { + subject: "Test Subject", + text: "Test message", + }, + }; + expect(() => validatePayload(invalidPayload)).toThrow(ValidationError); + expect(() => validatePayload(invalidPayload)).toThrow( + "Invalid email configuration: Field 'to' must be either a string or an array of strings" + ); + }); + + // Test missing message/template/sendGrid + it("should throw ValidationError when no message, template, or sendGrid is provided", () => { + const invalidPayload = { + to: "test@example.com", + }; + expect(() => validatePayload(invalidPayload)).toThrow(ValidationError); + expect(() => validatePayload(invalidPayload)).toThrow( + "Email configuration must include either a 'message', 'template', or 'sendGrid' object" + ); + }); + + // Test missing subject + it("should throw ValidationError for message without subject", () => { + const invalidPayload = { + to: "test@example.com", + message: { + text: "Test message", + }, + }; + expect(() => validatePayload(invalidPayload)).toThrow(ValidationError); + expect(() => validatePayload(invalidPayload)).toThrow( + "Invalid email configuration: Field 'message.subject' must be a string" + ); + }); +}); diff --git a/firestore-send-email/functions/jest.config.js b/firestore-send-email/functions/jest.config.js index 7e17ea806..6ead6a89f 100644 --- a/firestore-send-email/functions/jest.config.js +++ b/firestore-send-email/functions/jest.config.js @@ -15,7 +15,7 @@ module.exports = { printBasicPrototype: true, }, setupFiles: ["/__tests__/jest.setup.ts"], - testMatch: ["**/__tests__/*.test.ts"], + testMatch: ["**/__tests__/**/*.test.ts"], testEnvironment: "node", moduleNameMapper: { "firebase-admin/app": "/node_modules/firebase-admin/lib/app", diff --git a/firestore-send-email/functions/package-lock.json b/firestore-send-email/functions/package-lock.json index 7c3e56c2a..9754fb1c8 100644 --- a/firestore-send-email/functions/package-lock.json +++ b/firestore-send-email/functions/package-lock.json @@ -18,7 +18,8 @@ "rimraf": "^2.6.3", "smtp-server": "^3.13.4", "typescript": "^5.7.3", - "wait-on": "^7.2.0" + "wait-on": "^7.2.0", + "zod": "^3.24.4" }, "devDependencies": { "@types/jest": "29.5.0", @@ -6285,6 +6286,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", + "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/firestore-send-email/functions/package.json b/firestore-send-email/functions/package.json index 25a91c770..e9f93091b 100644 --- a/firestore-send-email/functions/package.json +++ b/firestore-send-email/functions/package.json @@ -30,7 +30,8 @@ "rimraf": "^2.6.3", "smtp-server": "^3.13.4", "typescript": "^5.7.3", - "wait-on": "^7.2.0" + "wait-on": "^7.2.0", + "zod": "^3.24.4" }, "devDependencies": { "@types/jest": "29.5.0", diff --git a/firestore-send-email/functions/src/index.ts b/firestore-send-email/functions/src/index.ts index ad88e0a44..112ea1889 100644 --- a/firestore-send-email/functions/src/index.ts +++ b/firestore-send-email/functions/src/index.ts @@ -31,10 +31,11 @@ import * as nodemailer from "nodemailer"; import * as logs from "./logs"; import config from "./config"; import Templates from "./templates"; -import { QueuePayload, SendGridAttachment } from "./types"; +import { Delivery, QueuePayload, ExtendedSendMailOptions } from "./types"; import { isSendGrid, setSmtpCredentials } from "./helpers"; import * as events from "./events"; -import * as sgMail from "@sendgrid/mail"; +import { SendGridTransport } from "./nodemailer-sendgrid"; +import { validatePayload } from "./validation"; logs.init(); @@ -43,6 +44,15 @@ let transport: nodemailer.Transporter; let templates: Templates; let initialized = false; +interface SendMailInfoLike { + messageId: string | null; + sendgridQueueId?: string | null; + accepted: string[]; + rejected: string[]; + pending: string[]; + response: string | null; +} + /** * Initializes Admin SDK & SMTP connection if not already initialized. */ @@ -71,7 +81,13 @@ async function transportLayer() { }, }); } - + if (isSendGrid(config)) { + // use our custom transport + return nodemailer.createTransport( + new SendGridTransport({ apiKey: config.smtpPassword }) + ); + } + // fallback to any other SMTP provider return setSmtpCredentials(config); } @@ -109,6 +125,9 @@ function getExpireAt(startTime: Timestamp) { } async function preparePayload(payload: DocumentData): Promise { + // Validate the payload before processing + validatePayload(payload); + const { template } = payload; if (templates && template) { @@ -183,7 +202,7 @@ async function preparePayload(payload: DocumentData): Promise { uids = uids.concat(payload.bccUids); } - const toFetch = {}; + const toFetch: Record = {}; uids.forEach((uid) => (toFetch[uid] = null)); const documents = await db.getAll( @@ -195,7 +214,7 @@ async function preparePayload(payload: DocumentData): Promise { } ); - const missingUids = []; + const missingUids: string[] = []; documents.forEach((documentSnapshot) => { if (documentSnapshot.exists) { @@ -249,72 +268,6 @@ async function preparePayload(payload: DocumentData): Promise { return payload; } -/** - * If the SMTP provider is SendGrid, we need to check if the payload contains - * either a text or html content, or if the payload contains a SendGrid Dynamic Template. - * - * Throws an error if all of the above are not provided. - * - * @param payload the payload from Firestore. - */ - -async function sendWithSendGrid(payload: DocumentData) { - sgMail.setApiKey(config.smtpPassword); - - const formatEmails = (emails: string[]) => emails.map((email) => ({ email })); - - // Transform attachments to match SendGrid's expected format - const formatAttachments = ( - attachments: QueuePayload["message"]["attachments"] = [] - ): SendGridAttachment[] => { - return attachments.map((attachment) => ({ - content: (attachment.content as string | undefined) || "", // Base64-encoded string - filename: attachment.filename || "attachment", - type: attachment.contentType, - disposition: attachment.contentDisposition, - contentId: attachment.cid, - })); - }; - - const replyTo = { email: payload.replyTo || config.defaultReplyTo }; - - const attachments = payload.message?.attachments; - - // Build the message object for SendGrid - const msg: sgMail.MailDataRequired = { - to: formatEmails(payload.to), - cc: formatEmails(payload.cc), - bcc: formatEmails(payload.bcc), - from: { email: payload.from || config.defaultFrom }, - replyTo: replyTo.email ? replyTo : undefined, - subject: payload.message?.subject, - text: - typeof payload.message?.text === "string" - ? payload.message?.text - : undefined, - html: - typeof payload.message?.html === "string" - ? payload.message?.html - : undefined, - categories: payload.categories, // SendGrid-specific field - headers: payload.headers, - attachments: formatAttachments(attachments), // Transform attachments to SendGrid format - mailSettings: payload.sendGrid?.mailSettings || {}, // SendGrid-specific mail settings - }; - - // If a SendGrid template is provided, include templateId and dynamicTemplateData - if (payload.sendGrid?.templateId) { - msg.templateId = payload.sendGrid.templateId; - msg.dynamicTemplateData = payload.sendGrid.dynamicTemplateData || {}; - } - - // Log the final message payload for debugging - logs.info("SendGrid message payload constructed", { msg }); - - // Send the message using SendGrid's API - return sgMail.send(msg); -} - async function deliver(ref: DocumentReference): Promise { // Fetch the Firestore document const snapshot = await ref.get(); @@ -350,33 +303,28 @@ async function deliver(ref: DocumentReference): Promise { ); } - let result; + const mailOptions: ExtendedSendMailOptions = { + from: payload.from || config.defaultFrom, + replyTo: payload.replyTo || config.defaultReplyTo, + to: payload.to, + cc: payload.cc, + bcc: payload.bcc, + subject: payload.message?.subject, + text: payload.message?.text, + html: payload.message?.html, + attachments: payload.message?.attachments, + categories: payload.categories, + templateId: payload.sendGrid?.templateId, + dynamicTemplateData: payload.sendGrid?.dynamicTemplateData, + mailSettings: payload.sendGrid?.mailSettings, + }; - // Automatically detect SendGrid - if (isSendGrid(config)) { - logs.info("Using SendGrid for email delivery", { - msg: payload, - }); - result = await sendWithSendGrid(payload); // Use the SendGrid-specific function - } else { - logs.info("Using standard transport for email delivery.", { - msg: payload, - }); - // Use the default transport for other SMTP providers - result = await transport.sendMail({ - ...Object.assign(payload.message ?? {}, { - from: payload.from || config.defaultFrom, - replyTo: payload.replyTo || config.defaultReplyTo, - to: payload.to, - cc: payload.cc, - bcc: payload.bcc, - headers: payload.headers || {}, - }), - }); - } + logs.info("Sending via transport.sendMail()", { mailOptions }); + const result = (await transport.sendMail(mailOptions)) as any; - const info = { + const info: SendMailInfoLike = { messageId: result.messageId || null, + sendgridQueueId: result.queueId || null, accepted: result.accepted || [], rejected: result.rejected || [], pending: result.pending || [], @@ -417,7 +365,7 @@ async function processWrite( // Note: we still check these again inside the transaction in case the state has // changed while the transaction was inflight. if (change.after.exists) { - const payloadAfter = change.after.data(); + const payloadAfter = change.after.data() as QueuePayload; // The email has already been delivered, so we don't need to do anything. if ( payloadAfter && @@ -445,7 +393,7 @@ async function processWrite( return false; } - const payload = snapshot.data(); + const payload = snapshot.data() as QueuePayload; // We expect the payload to contain a message object describing the email // to be sent, or a template, or a SendGrid template. @@ -463,22 +411,16 @@ async function processWrite( // initialize the delivery state. if (!payload.delivery) { const startTime = Timestamp.fromDate(new Date()); - - const delivery = { - startTime: Timestamp.fromDate(new Date()), + const delivery: Partial = { + startTime: startTime, state: "PENDING", attempts: 0, error: null, }; - if (config.TTLExpireType && config.TTLExpireType !== "never") { - delivery["expireAt"] = getExpireAt(startTime); + delivery.expireAt = getExpireAt(startTime); } - - transaction.update(ref, { - //@ts-ignore - delivery, - }); + transaction.update(ref, { delivery }); // We've updated the payload, so we need to attempt delivery, but we // don't want to do it in this transaction. Since the transaction will // update the record again the cloud function will be triggered again @@ -486,21 +428,21 @@ async function processWrite( return false; } + const state = payload.delivery.state; // The email has already been delivered, so we don't need to do anything. - if (payload.delivery.state === "SUCCESS") { + if (state === "SUCCESS") { await events.recordSuccessEvent(change); return false; } // The email has previously failed to be delivered, so we can't do anything. - if (payload.delivery.state === "ERROR") { + if (state === "ERROR") { await events.recordErrorEvent(change, payload, payload.delivery.error); return false; } - if (payload.delivery.state === "PROCESSING") { + if (state === "PROCESSING") { await events.recordProcessingEvent(change); - if (payload.delivery.leaseExpireTime.toMillis() < Date.now()) { const error = "Message processing lease expired."; @@ -520,20 +462,12 @@ async function processWrite( return false; } - if (payload.delivery.state === "PENDING") { - await events.recordPendingEvent(change, payload); - - // We can attempt to deliver the email in these states, so we set the state to PROCESSING - // and set a lease time to prevent delivery from being attempted forever. - transaction.update(ref, { - "delivery.state": "PROCESSING", - "delivery.leaseExpireTime": Timestamp.fromMillis(Date.now() + 60000), - }); - return true; - } - - if (payload.delivery.state === "RETRY") { - await events.recordRetryEvent(change, payload); + if (state === "PENDING" || state === "RETRY") { + const eventFn = + state === "PENDING" + ? events.recordPendingEvent + : events.recordRetryEvent; + await eventFn(change, payload); // We can attempt to deliver the email in these states, so we set the state to PROCESSING // and set a lease time to prevent delivery from being attempted forever. @@ -568,19 +502,17 @@ export const processQueue = onDocumentWritten( try { await processWrite(change); - } catch (err) { + } catch (err: any) { await events.recordErrorEvent( change, change.after.data(), - `Unhandled error occurred during processing: ${err.message}"` + `Unhandled error occurred during processing: ${err.message}` ); logs.error(err); return null; } - /** record complete event */ await events.recordCompleteEvent(change); - logs.complete(); } ); diff --git a/firestore-send-email/functions/src/nodemailer-sendgrid/index.ts b/firestore-send-email/functions/src/nodemailer-sendgrid/index.ts new file mode 100644 index 000000000..9d7d24c78 --- /dev/null +++ b/firestore-send-email/functions/src/nodemailer-sendgrid/index.ts @@ -0,0 +1,215 @@ +import * as sgMail from "@sendgrid/mail"; +import { + SendGridTransportOptions, + Address, + MailSource, + SendGridInfo, + SendGridAttachment, + SendGridContent, + SendGridMessage, +} from "./types"; + +/** + * A Nodemailer transport implementation for SendGrid. + * This class handles the conversion of Nodemailer mail objects to SendGrid's format + * and sends emails using the SendGrid API. + */ +export class SendGridTransport { + /** The name of the transport */ + public readonly name = "firebase-extensions-nodemailer-sendgrid"; + /** The version of the transport */ + public readonly version = "0.0.1"; + /** The SendGrid transport options */ + private readonly options: SendGridTransportOptions; + + /** + * Creates a new SendGridTransport instance. + * @param options - Configuration options for the SendGrid transport + * @param options.apiKey - SendGrid API key for authentication + */ + constructor(options: SendGridTransportOptions = {}) { + this.options = options; + if (options.apiKey) { + sgMail.setApiKey(options.apiKey); + } + } + + /** + * Sends an email using SendGrid. + * @param mail - The mail object containing email details (from, to, subject, etc.) + * @param callback - Callback function to handle the result of the send operation + * @param callback.err - Error object if the send operation failed + * @param callback.info - Information about the sent email if successful + */ + public send( + mail: MailSource, + callback: (err: Error | null, info?: SendGridInfo) => void + ): void { + mail.normalize((err, source) => { + if (err) { + return callback(err); + } + + const msg: SendGridMessage = {}; + + for (const key of Object.keys(source)) { + switch (key) { + case "subject": + case "text": + case "html": + msg[key] = source[key]; + break; + + case "from": + case "replyTo": { + const list = ([] as Address[]).concat(source[key] || []); + const e = list[0]; + if (e) { + msg[key] = { name: e.name, email: e.address }; + } + break; + } + + case "to": + case "cc": + case "bcc": { + const list = ([] as Address[]).concat(source[key] || []); + msg[key] = list.map((e) => ({ name: e.name, email: e.address })); + break; + } + + case "attachments": { + const atchs = source.attachments || []; + msg.attachments = atchs.map((entry) => ({ + content: entry.content.toString(), + filename: entry.filename, + type: entry.contentType, + disposition: entry.cid ? "inline" : "attachment", + ...(entry.cid ? { content_id: entry.cid } : {}), + })); + break; + } + + case "alternatives": { + const alts = source.alternatives || []; + const fmt = alts.map((alt) => ({ + type: alt.contentType, + value: alt.content, + })); + msg.content = ([] as SendGridContent[]) + .concat(msg.content || []) + .concat(fmt); + break; + } + + case "icalEvent": { + const ev = source.icalEvent!; + const cal: SendGridAttachment = { + content: ev.content, + filename: ev.filename || "invite.ics", + type: "application/ics", + disposition: "attachment", + }; + msg.attachments = ([] as SendGridAttachment[]) + .concat(msg.attachments || []) + .concat(cal); + break; + } + + case "watchHtml": { + msg.content = ([] as SendGridContent[]) + .concat(msg.content || []) + .concat({ + type: "text/watch-html", + value: source.watchHtml!, + }); + break; + } + + case "normalizedHeaders": + msg.headers = { + ...(msg.headers || {}), + ...source.normalizedHeaders, + }; + break; + + case "messageId": + msg.headers = { + ...(msg.headers || {}), + "message-id": source.messageId!, + }; + break; + + case "categories": + msg.categories = source.categories; + break; + case "templateId": + msg.templateId = source.templateId; + break; + case "dynamicTemplateData": + msg.dynamicTemplateData = source.dynamicTemplateData; + break; + case "mailSettings": + msg.mailSettings = source.mailSettings; + break; + + default: + msg[key] = source[key]; + } + } + + // If we built a msg.content array, ensure text/html are injected + if (Array.isArray(msg.content) && msg.content.length) { + if (msg.text) { + msg.content.unshift({ type: "text/plain", value: msg.text }); + delete msg.text; + } + if (msg.html) { + msg.content.unshift({ type: "text/html", value: msg.html }); + delete msg.html; + } + } + + sgMail + .send(msg as sgMail.MailDataRequired) + .then(([response]) => { + // Internal SendGrid queue-ID from HTTP header + const rawQueueId = (response.headers["x-message-id"] || + response.headers["X-Message-Id"]) as string | undefined; + const queueId = rawQueueId ? String(rawQueueId) : null; + + // RFC-2822 Message-ID header + const headerMsgId = (msg.headers && msg.headers["message-id"]) as + | string + | undefined; + const messageId = headerMsgId || null; + + // Include all recipients (to, cc, bcc) in accepted array + const toList = ([] as Array<{ email: string }>).concat(msg.to || []); + const ccList = ([] as Array<{ email: string }>).concat(msg.cc || []); + const bccList = ([] as Array<{ email: string }>).concat( + msg.bcc || [] + ); + const accepted = Array.from( + new Set( + [...toList, ...ccList, ...bccList].map((r) => + (typeof r === "string" ? r : r.email).toLowerCase() + ) + ) + ); + + const info: SendGridInfo = { + messageId, + queueId, + accepted, + rejected: [], + pending: [], + response: `status=${response.statusCode}`, + }; + + callback(null, info); + }) + .catch((sendErr: Error) => callback(sendErr)); + }); + } +} diff --git a/firestore-send-email/functions/src/nodemailer-sendgrid/types.ts b/firestore-send-email/functions/src/nodemailer-sendgrid/types.ts new file mode 100644 index 000000000..f8e6e626c --- /dev/null +++ b/firestore-send-email/functions/src/nodemailer-sendgrid/types.ts @@ -0,0 +1,95 @@ +export interface SendGridTransportOptions { + apiKey?: string; + [key: string]: unknown; +} + +export interface Address { + name?: string; + address: string; +} + +export interface AttachmentEntry { + content: string | Buffer; + filename: string; + contentType: string; + cid?: string; +} + +export interface IcalEvent { + content: string; + filename?: string; +} + +export interface NormalizedHeaders { + [header: string]: string; +} + +export interface MailSource { + normalize(cb: (err: Error | null, source: MailSource) => void): void; + + from?: Address | Address[]; + replyTo?: Address | Address[]; + to?: Address | Address[]; + cc?: Address | Address[]; + bcc?: Address | Address[]; + subject?: string; + text?: string; + html?: string; + attachments?: AttachmentEntry[]; + alternatives?: { content: string; contentType: string }[]; + icalEvent?: IcalEvent; + watchHtml?: string; + normalizedHeaders?: NormalizedHeaders; + messageId?: string; + + categories?: string[]; + templateId?: string; + dynamicTemplateData?: Record; + mailSettings?: Record; + + [key: string]: unknown; +} + +export interface SendGridInfo { + /** The RFC-2822 Message-ID header (what you set or SendGrid generated) */ + messageId: string | null; + /** SendGrid's internal queue token (the X-Message-Id HTTP header) */ + queueId: string | null; + accepted: string[]; + rejected: string[]; + pending: string[]; + /** HTTP status line, e.g. "status=202" */ + response: string; +} + +export interface SendGridAttachment { + content: string; + filename: string; + type: string; + disposition: string; + content_id?: string; +} + +export interface SendGridContent { + type: string; + value: string; +} + +export interface SendGridMessage { + subject?: string; + text?: string; + html?: string; + from?: { name?: string; email: string }; + replyTo?: { name?: string; email: string }; + to?: Array<{ name?: string; email: string }>; + cc?: Array<{ name?: string; email: string }>; + bcc?: Array<{ name?: string; email: string }>; + attachments?: SendGridAttachment[]; + content?: SendGridContent[]; + headers?: Record; + categories?: string[]; + templateId?: string; + dynamicTemplateData?: Record; + mailSettings?: Record; + [key: string]: unknown; +} diff --git a/firestore-send-email/functions/src/types.ts b/firestore-send-email/functions/src/types.ts index bd995d48c..e03e74e74 100644 --- a/firestore-send-email/functions/src/types.ts +++ b/firestore-send-email/functions/src/types.ts @@ -59,21 +59,24 @@ export interface TemplateData { attachments?: Attachment[]; } -export interface QueuePayload { - delivery?: { - startTime: admin.firestore.Timestamp; - endTime: admin.firestore.Timestamp; - leaseExpireTime: admin.firestore.Timestamp; - state: "PENDING" | "PROCESSING" | "RETRY" | "SUCCESS" | "ERROR"; - attempts: number; - error?: string; - info?: { - messageId: string; - accepted: string[]; - rejected: string[]; - pending: string[]; - }; +export interface Delivery { + startTime: admin.firestore.Timestamp; + endTime: admin.firestore.Timestamp; + leaseExpireTime: admin.firestore.Timestamp; + state: "PENDING" | "PROCESSING" | "RETRY" | "SUCCESS" | "ERROR"; + attempts: number; + error?: string; + expireAt?: admin.firestore.Timestamp; + info?: { + messageId: string; + accepted: string[]; + rejected: string[]; + pending: string[]; }; +} + +export interface QueuePayload { + delivery?: Delivery; message?: nodemailer.SendMailOptions; template?: { name: string; @@ -98,13 +101,13 @@ export interface QueuePayload { } // Define the expected format for SendGrid attachments -export type SendGridAttachment = { +export interface SendGridAttachment { content: string; // Base64-encoded string filename: string; type?: string; disposition?: string; contentId?: string; -}; +} export enum AuthenticatonType { OAuth2 = "OAuth2", @@ -118,3 +121,10 @@ export enum Hosts { Outlook = "smtp-mail.outlook.com", Hotmail = "smtp.live.com", } + +export interface ExtendedSendMailOptions extends nodemailer.SendMailOptions { + categories?: string[]; + templateId?: string; + dynamicTemplateData?: Record; + mailSettings?: Record; +} diff --git a/firestore-send-email/functions/src/validation.ts b/firestore-send-email/functions/src/validation.ts new file mode 100644 index 000000000..cbbafa336 --- /dev/null +++ b/firestore-send-email/functions/src/validation.ts @@ -0,0 +1,151 @@ +import { z } from "zod"; +import { logger } from "firebase-functions/v1"; + +// Custom error class for validation errors +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } +} + +// Standard message schema (for non-template, non-SendGrid) +const standardMessageSchema = z + .object({ + subject: z.string(), + text: z.string().optional(), + html: z.string().optional(), + attachments: z.array(z.any()).optional(), + }) + .refine((data) => !!data.text || !!data.html, { + message: "At least one of 'text' or 'html' must be provided in message", + }); + +// SendGrid template schema +const sendGridSchema = z.object({ + templateId: z.string(), + dynamicTemplateData: z.record(z.any()).optional(), + mailSettings: z.record(z.any()).optional(), +}); + +// Template schema +const templateSchema = z.object({ + name: z.string(), + data: z.record(z.any()).optional(), +}); + +// Main payload schema +const payloadSchema = z.object({ + to: z.union([z.string(), z.array(z.string())]).optional(), + cc: z.union([z.string(), z.array(z.string())]).optional(), + bcc: z.union([z.string(), z.array(z.string())]).optional(), + toUids: z.array(z.string()).optional(), + ccUids: z.array(z.string()).optional(), + bccUids: z.array(z.string()).optional(), + from: z.string().optional(), + replyTo: z.string().optional(), + message: standardMessageSchema.optional(), + template: templateSchema.optional(), + sendGrid: sendGridSchema.optional(), + categories: z.array(z.string()).optional(), +}); + +function formatZodError(error: z.ZodError): string { + const issues = error.issues.map((issue) => { + const path = issue.path.join("."); + switch (issue.code) { + case "invalid_type": + if (issue.expected === "string") { + return `Field '${path}' must be a string`; + } + if (issue.expected === "array") { + return `Field '${path}' must be an array`; + } + return `Field '${path}' must be ${issue.expected}`; + case "invalid_string": + return `Field '${path}' is invalid`; + case "too_small": + return `Field '${path}' is required`; + case "invalid_union": + return `Field '${path}' must be either a string or an array of strings`; + default: + return issue.message; + } + }); + return issues.join(". "); +} + +export function validatePayload(payload: any) { + try { + // First validate the overall payload structure + const result = payloadSchema.safeParse(payload); + if (!result.success) { + throw new ValidationError( + `Invalid email configuration: ${formatZodError(result.error)}` + ); + } + + // If using SendGrid template, validate sendGrid object + if (payload.sendGrid) { + const sendGridResult = sendGridSchema.safeParse(payload.sendGrid); + if (!sendGridResult.success) { + throw new ValidationError( + `Invalid SendGrid configuration: ${formatZodError( + sendGridResult.error + )}` + ); + } + return; + } + + // If using custom template, validate template object + if (payload.template) { + const templateResult = templateSchema.safeParse(payload.template); + if (!templateResult.success) { + throw new ValidationError( + `Invalid template configuration: ${formatZodError( + templateResult.error + )}` + ); + } + return; + } + + // If not using templates, validate message object + if (!payload.message) { + throw new ValidationError( + "Email configuration must include either a 'message', 'template', or 'sendGrid' object" + ); + } + + const messageResult = standardMessageSchema.safeParse(payload.message); + if (!messageResult.success) { + throw new ValidationError( + `Invalid message configuration: ${formatZodError(messageResult.error)}` + ); + } + + // Validate that there is at least one recipient + if ( + !payload.to?.length && + !payload.cc?.length && + !payload.bcc?.length && + !payload.toUids?.length && + !payload.ccUids?.length && + !payload.bccUids?.length + ) { + throw new ValidationError( + "Email must have at least one recipient (to, cc, bcc, toUids, ccUids, or bccUids)" + ); + } + } catch (error) { + if (error instanceof ValidationError) { + logger.error("Validation failed:", error.message); + throw error; + } + logger.error("Unexpected validation error:", error); + throw new ValidationError( + "An unexpected error occurred while validating the email configuration" + ); + } +}