diff --git a/firestore-send-email/functions/__tests__/delivery.integration.test.ts b/firestore-send-email/functions/__tests__/delivery.integration.test.ts
new file mode 100644
index 000000000..1891618c6
--- /dev/null
+++ b/firestore-send-email/functions/__tests__/delivery.integration.test.ts
@@ -0,0 +1,906 @@
+import { deliverEmail, DeliveryDependencies } from "../src/delivery";
+import { setDependencies } from "../src/prepare-payload";
+
+/**
+ * Helper to create a mock DocumentReference with the given payload
+ */
+function createMockRef(payload: any) {
+ return {
+ get: jest.fn().mockResolvedValue({
+ exists: true,
+ data: () => payload,
+ }),
+ path: "mail/test-doc-id",
+ } as any;
+}
+
+/**
+ * Helper to create a mock DocumentReference that doesn't exist
+ */
+function createNonExistentRef() {
+ return {
+ get: jest.fn().mockResolvedValue({
+ exists: false,
+ }),
+ path: "mail/non-existent",
+ } as any;
+}
+
+describe("deliverEmail integration", () => {
+ const mockTransport = {
+ sendMail: jest.fn(),
+ };
+
+ const deps: DeliveryDependencies = {
+ transport: mockTransport as any,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Mock dependencies for preparePayload (db and templates)
+ setDependencies({ getAll: jest.fn() }, null);
+
+ // Default successful transport response
+ mockTransport.sendMail.mockResolvedValue({
+ messageId: "test-msg-123",
+ accepted: ["user@example.com"],
+ rejected: [],
+ pending: [],
+ response: "250 OK",
+ });
+ });
+
+ describe("basic delivery scenarios", () => {
+ test("delivers email with text content", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "Test Subject", text: "Plain text content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(result.info?.messageId).toBe("test-msg-123");
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: ["user@example.com"],
+ subject: "Test Subject",
+ text: "Plain text content",
+ })
+ );
+ });
+
+ test("delivers email with HTML content", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "HTML Email", html: "
Hello
World
" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ html: "Hello
World
",
+ })
+ );
+ });
+
+ test("delivers email with both text and HTML content", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: {
+ subject: "Multi-format Email",
+ text: "Plain text version",
+ html: "HTML version
",
+ },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ text: "Plain text version",
+ html: "HTML version
",
+ })
+ );
+ });
+
+ test("returns delivery info with all fields", async () => {
+ mockTransport.sendMail.mockResolvedValue({
+ messageId: "unique-id-456",
+ queueId: "sendgrid-queue-789",
+ accepted: ["a@test.com", "b@test.com"],
+ rejected: ["invalid@test.com"],
+ pending: ["slow@test.com"],
+ response: "250 Message accepted",
+ });
+
+ const mockRef = createMockRef({
+ to: ["a@test.com", "b@test.com", "invalid@test.com", "slow@test.com"],
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(result.info).toEqual({
+ messageId: "unique-id-456",
+ sendgridQueueId: "sendgrid-queue-789",
+ accepted: ["a@test.com", "b@test.com"],
+ rejected: ["invalid@test.com"],
+ pending: ["slow@test.com"],
+ response: "250 Message accepted",
+ });
+ });
+ });
+
+ describe("recipient handling", () => {
+ test("converts single string recipient to array", async () => {
+ const mockRef = createMockRef({
+ to: "single@example.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: ["single@example.com"],
+ })
+ );
+ });
+
+ test("handles array of recipients", async () => {
+ const mockRef = createMockRef({
+ to: ["user1@example.com", "user2@example.com", "user3@example.com"],
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: ["user1@example.com", "user2@example.com", "user3@example.com"],
+ })
+ );
+ });
+
+ test("handles all recipient types (to, cc, bcc)", async () => {
+ const mockRef = createMockRef({
+ to: ["to1@example.com", "to2@example.com"],
+ cc: "cc@example.com",
+ bcc: ["bcc1@example.com", "bcc2@example.com"],
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: ["to1@example.com", "to2@example.com"],
+ cc: ["cc@example.com"],
+ bcc: ["bcc1@example.com", "bcc2@example.com"],
+ })
+ );
+ });
+
+ test("handles cc only (no to)", async () => {
+ const mockRef = createMockRef({
+ cc: "cc-only@example.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: [],
+ cc: ["cc-only@example.com"],
+ })
+ );
+ });
+
+ test("handles bcc only (no to or cc)", async () => {
+ const mockRef = createMockRef({
+ bcc: ["bcc-only@example.com"],
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: [],
+ cc: [],
+ bcc: ["bcc-only@example.com"],
+ })
+ );
+ });
+
+ test("fails when no recipients at all", async () => {
+ const mockRef = createMockRef({
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+ expect(mockTransport.sendMail).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("delivery state handling", () => {
+ test("processes when state is PROCESSING", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(result.skipped).toBeUndefined();
+ expect(mockTransport.sendMail).toHaveBeenCalled();
+ });
+
+ test("skips when state is PENDING", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PENDING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(false);
+ expect(result.skipped).toBe(true);
+ expect(mockTransport.sendMail).not.toHaveBeenCalled();
+ });
+
+ test("skips when state is SUCCESS", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "SUCCESS" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(false);
+ expect(result.skipped).toBe(true);
+ expect(mockTransport.sendMail).not.toHaveBeenCalled();
+ });
+
+ test("skips when state is ERROR", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "ERROR" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(false);
+ expect(result.skipped).toBe(true);
+ expect(mockTransport.sendMail).not.toHaveBeenCalled();
+ });
+
+ test("skips when state is RETRY", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "RETRY" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(false);
+ expect(result.skipped).toBe(true);
+ expect(mockTransport.sendMail).not.toHaveBeenCalled();
+ });
+
+ test("skips when delivery object is missing", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "Test", text: "Content" },
+ // No delivery object
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(false);
+ expect(result.skipped).toBe(true);
+ expect(mockTransport.sendMail).not.toHaveBeenCalled();
+ });
+
+ test("skips when document does not exist", async () => {
+ const mockRef = createNonExistentRef();
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(false);
+ expect(result.skipped).toBe(true);
+ expect(result.error).toContain("does not exist");
+ expect(mockTransport.sendMail).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("SendGrid configuration", () => {
+ test("handles SendGrid templateId", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ sendGrid: {
+ templateId: "d-abc123def456",
+ },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ templateId: "d-abc123def456",
+ })
+ );
+ });
+
+ test("handles SendGrid with dynamicTemplateData", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ sendGrid: {
+ templateId: "d-template-id",
+ dynamicTemplateData: {
+ firstName: "John",
+ lastName: "Doe",
+ orderNumber: 12345,
+ items: ["Item A", "Item B"],
+ },
+ },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ templateId: "d-template-id",
+ dynamicTemplateData: {
+ firstName: "John",
+ lastName: "Doe",
+ orderNumber: 12345,
+ items: ["Item A", "Item B"],
+ },
+ })
+ );
+ });
+
+ test("handles SendGrid with mailSettings", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ sendGrid: {
+ templateId: "d-template-id",
+ mailSettings: {
+ sandboxMode: { enable: true },
+ bypassListManagement: { enable: false },
+ },
+ },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mailSettings: {
+ sandboxMode: { enable: true },
+ bypassListManagement: { enable: false },
+ },
+ })
+ );
+ });
+
+ test("handles SendGrid with all options combined", async () => {
+ const mockRef = createMockRef({
+ to: ["user1@example.com", "user2@example.com"],
+ cc: "cc@example.com",
+ from: "sender@company.com",
+ replyTo: "support@company.com",
+ sendGrid: {
+ templateId: "d-full-template",
+ dynamicTemplateData: { name: "Customer" },
+ mailSettings: { sandboxMode: { enable: false } },
+ },
+ categories: ["transactional", "welcome"],
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: ["user1@example.com", "user2@example.com"],
+ cc: ["cc@example.com"],
+ from: "sender@company.com",
+ replyTo: "support@company.com",
+ templateId: "d-full-template",
+ dynamicTemplateData: { name: "Customer" },
+ mailSettings: { sandboxMode: { enable: false } },
+ categories: ["transactional", "welcome"],
+ })
+ );
+ });
+
+ test("handles SendGrid template with message content override", async () => {
+ // When both sendGrid.templateId and message content are provided,
+ // the message fields should still be passed to allow fallback/override
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ sendGrid: {
+ templateId: "d-my-template",
+ dynamicTemplateData: { name: "John" },
+ },
+ message: {
+ subject: "Fallback Subject",
+ text: "Fallback text if template fails",
+ },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ templateId: "d-my-template",
+ dynamicTemplateData: { name: "John" },
+ subject: "Fallback Subject",
+ text: "Fallback text if template fails",
+ })
+ );
+ });
+
+ test("handles SendGrid with empty message object", async () => {
+ // SendGrid with empty message should still work (template provides content)
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ sendGrid: {
+ templateId: "d-template-provides-all",
+ },
+ message: {},
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ templateId: "d-template-provides-all",
+ })
+ );
+ });
+
+ test("handles SendGrid mailSettings only (no templateId)", async () => {
+ // mailSettings can be used without templateId for sandbox mode etc
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ sendGrid: {
+ mailSettings: {
+ sandboxMode: { enable: true },
+ },
+ },
+ message: {
+ subject: "Test in Sandbox",
+ text: "This email is in sandbox mode",
+ },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ subject: "Test in Sandbox",
+ text: "This email is in sandbox mode",
+ mailSettings: {
+ sandboxMode: { enable: true },
+ },
+ })
+ );
+ });
+ });
+
+ describe("attachments", () => {
+ test("handles message with attachments array", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: {
+ subject: "Email with attachments",
+ text: "See attached files",
+ attachments: [
+ { filename: "report.pdf", content: "base64content" },
+ { filename: "image.png", path: "/path/to/image.png" },
+ ],
+ },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attachments: [
+ { filename: "report.pdf", content: "base64content" },
+ { filename: "image.png", path: "/path/to/image.png" },
+ ],
+ })
+ );
+ });
+
+ test("handles message with empty attachments array", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: {
+ subject: "No attachments",
+ text: "Plain email",
+ attachments: [],
+ },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ });
+
+ test("handles attachment with all properties", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: {
+ subject: "Full attachment",
+ text: "Content",
+ attachments: [
+ {
+ filename: "document.pdf",
+ content: "base64data",
+ contentType: "application/pdf",
+ contentDisposition: "attachment",
+ cid: "unique-cid",
+ encoding: "base64",
+ },
+ ],
+ },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ attachments: expect.arrayContaining([
+ expect.objectContaining({
+ filename: "document.pdf",
+ contentType: "application/pdf",
+ }),
+ ]),
+ })
+ );
+ });
+ });
+
+ describe("custom email options", () => {
+ test("uses custom from address", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ from: "custom-sender@company.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ from: "custom-sender@company.com",
+ })
+ );
+ });
+
+ test("uses custom replyTo address", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ replyTo: "support@company.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ replyTo: "support@company.com",
+ })
+ );
+ });
+
+ test("includes custom headers", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ headers: {
+ "X-Custom-Header": "custom-value",
+ "X-Priority": "1",
+ },
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ headers: {
+ "X-Custom-Header": "custom-value",
+ "X-Priority": "1",
+ },
+ })
+ );
+ });
+
+ test("includes categories", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ categories: ["marketing", "newsletter", "weekly"],
+ message: { subject: "Newsletter", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ categories: ["marketing", "newsletter", "weekly"],
+ })
+ );
+ });
+ });
+
+ describe("error handling", () => {
+ test("returns error when transport fails with SMTP error", async () => {
+ mockTransport.sendMail.mockRejectedValue(
+ new Error("SMTP connection refused")
+ );
+
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(false);
+ expect(result.skipped).toBeUndefined();
+ expect(result.error).toContain("SMTP connection refused");
+ });
+
+ test("returns error when transport times out", async () => {
+ mockTransport.sendMail.mockRejectedValue(
+ new Error("Connection timed out")
+ );
+
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("timed out");
+ });
+
+ test("returns error when authentication fails", async () => {
+ mockTransport.sendMail.mockRejectedValue(
+ new Error("Invalid login: 535 Authentication failed")
+ );
+
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("Authentication failed");
+ });
+
+ test("returns error for validation failure - missing message and template", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ // No message, template, or sendGrid
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+ expect(mockTransport.sendMail).not.toHaveBeenCalled();
+ });
+
+ test("returns error for invalid attachments (object instead of array)", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: {
+ subject: "Test",
+ text: "Content",
+ attachments: { filename: "bad.pdf" }, // Object, not array
+ },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("array");
+ expect(mockTransport.sendMail).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("edge cases", () => {
+ test("rejects empty message object without template or sendGrid", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: {},
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ // Empty message without template/sendGrid should fail - needs subject + content
+ expect(result.success).toBe(false);
+ expect(result.error).toContain("Invalid message configuration");
+ });
+
+ test("handles null values in transport response", async () => {
+ mockTransport.sendMail.mockResolvedValue({
+ messageId: null,
+ accepted: null,
+ rejected: null,
+ pending: null,
+ response: null,
+ });
+
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(result.info).toEqual({
+ messageId: null,
+ sendgridQueueId: null,
+ accepted: [],
+ rejected: [],
+ pending: [],
+ response: null,
+ });
+ });
+
+ test("handles very long recipient list", async () => {
+ const manyRecipients = Array.from(
+ { length: 100 },
+ (_, i) => `user${i}@example.com`
+ );
+
+ const mockRef = createMockRef({
+ to: manyRecipients,
+ message: { subject: "Mass email", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ to: manyRecipients,
+ })
+ );
+ });
+
+ test("handles unicode in email content", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: {
+ subject: "日本語の件名 🎉",
+ text: "Émojis: 👍 🚀 ✨",
+ html: "中文内容 with émojis 🌟
",
+ },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledWith(
+ expect.objectContaining({
+ subject: "日本語の件名 🎉",
+ text: "Émojis: 👍 🚀 ✨",
+ html: "中文内容 with émojis 🌟
",
+ })
+ );
+ });
+
+ test("handles special characters in email addresses", async () => {
+ const mockRef = createMockRef({
+ to: '"John Doe" ',
+ from: "Company Name ",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const result = await deliverEmail(mockRef, deps);
+
+ expect(result.success).toBe(true);
+ });
+ });
+
+ describe("concurrent delivery attempts", () => {
+ test("multiple calls with same ref return consistent results", async () => {
+ const mockRef = createMockRef({
+ to: "user@example.com",
+ message: { subject: "Test", text: "Content" },
+ delivery: { state: "PROCESSING" },
+ });
+
+ const [result1, result2, result3] = await Promise.all([
+ deliverEmail(mockRef, deps),
+ deliverEmail(mockRef, deps),
+ deliverEmail(mockRef, deps),
+ ]);
+
+ // All should succeed (though in production, the state machine would prevent duplicates)
+ expect(result1.success).toBe(true);
+ expect(result2.success).toBe(true);
+ expect(result3.success).toBe(true);
+ expect(mockTransport.sendMail).toHaveBeenCalledTimes(3);
+ });
+ });
+});
diff --git a/firestore-send-email/functions/__tests__/prepare-payload.test.ts b/firestore-send-email/functions/__tests__/prepare-payload.test.ts
index c9a7be394..e2fd8e3a0 100644
--- a/firestore-send-email/functions/__tests__/prepare-payload.test.ts
+++ b/firestore-send-email/functions/__tests__/prepare-payload.test.ts
@@ -1,3 +1,11 @@
+// Mock the config module before importing prepare-payload
+jest.mock("../src/config", () => ({
+ default: {
+ usersCollection: "users",
+ },
+ __esModule: true,
+}));
+
import { preparePayload, setDependencies } from "../src/prepare-payload";
class MockTemplates {
@@ -48,6 +56,12 @@ class MockTemplates {
text: undefined,
subject: "Template Subject",
};
+ case "template-with-object-attachments":
+ return {
+ html: "Template HTML
",
+ subject: "Template Subject",
+ attachments: { filename: "bad.pdf", content: "test" },
+ };
default:
return {};
}
@@ -144,6 +158,20 @@ describe("preparePayload Template Merging", () => {
expect(result.message.attachments).toEqual([{ filename: "template.pdf" }]);
});
+ it("should throw formatted error when template returns object attachments (not array)", async () => {
+ const payload = {
+ to: "test@example.com",
+ template: {
+ name: "template-with-object-attachments",
+ data: {},
+ },
+ };
+
+ await expect(preparePayload(payload)).rejects.toThrow(
+ "Field 'message.attachments' must be an array"
+ );
+ });
+
it("should gracefully handle template with no content", async () => {
const payload = {
to: "test@example.com",
@@ -251,15 +279,16 @@ describe("preparePayload Template Merging", () => {
expect(result.message.subject).toBe("Direct Subject");
});
- it("should handle empty message object", async () => {
+ it("should reject empty message object without template or sendGrid", async () => {
const payload = {
to: "test@example.com",
message: {},
};
- const result = await preparePayload(payload);
-
- expect(result.message).toEqual({});
+ // Empty message fails on subject requirement first
+ await expect(preparePayload(payload)).rejects.toThrow(
+ "Invalid message configuration"
+ );
});
it("should handle template with null values", async () => {
@@ -440,3 +469,197 @@ describe("preparePayload Template Merging", () => {
});
});
});
+
+describe("preparePayload", () => {
+ it("should throw error attachments object not in an array", async () => {
+ const payload = {
+ to: "test@example.com",
+ message: {
+ subject: "Test Subject",
+ text: "Test text",
+ attachments: {
+ filename: "test.txt",
+ content: "test",
+ },
+ },
+ };
+
+ await expect(preparePayload(payload)).rejects.toThrow(
+ "Invalid message configuration: Field 'message.attachments' must be an array"
+ );
+ });
+});
+
+describe("preparePayload UID resolution", () => {
+ const createMockDocSnapshot = (
+ id: string,
+ email: string | null,
+ exists: boolean = true
+ ) => ({
+ id,
+ exists,
+ get: (field: string) => (field === "email" ? email : undefined),
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("should resolve UIDs to emails successfully", async () => {
+ const mockGetAll = jest
+ .fn()
+ .mockResolvedValue([
+ createMockDocSnapshot("uid1", "user1@example.com"),
+ createMockDocSnapshot("uid2", "user2@example.com"),
+ ]);
+
+ const mockDb = {
+ getAll: mockGetAll,
+ collection: jest.fn().mockReturnValue({
+ doc: jest.fn().mockImplementation((uid) => ({ id: uid })),
+ }),
+ };
+
+ setDependencies(mockDb, null);
+
+ const payload = {
+ toUids: ["uid1", "uid2"],
+ message: {
+ subject: "Test Subject",
+ text: "Test text",
+ },
+ };
+
+ const result = await preparePayload(payload);
+
+ expect(result.to).toContain("user1@example.com");
+ expect(result.to).toContain("user2@example.com");
+ });
+
+ it("should handle mixed valid and invalid UIDs", async () => {
+ const mockGetAll = jest.fn().mockResolvedValue([
+ createMockDocSnapshot("uid1", "user1@example.com"),
+ createMockDocSnapshot("uid2", null), // User exists but no email
+ createMockDocSnapshot("uid3", "user3@example.com"),
+ createMockDocSnapshot("uid4", null, false), // User doesn't exist
+ ]);
+
+ const mockDb = {
+ getAll: mockGetAll,
+ collection: jest.fn().mockReturnValue({
+ doc: jest.fn().mockImplementation((uid) => ({ id: uid })),
+ }),
+ };
+
+ setDependencies(mockDb, null);
+
+ const payload = {
+ toUids: ["uid1", "uid2", "uid3", "uid4"],
+ message: {
+ subject: "Test Subject",
+ text: "Test text",
+ },
+ };
+
+ const result = await preparePayload(payload);
+
+ // Only valid emails should be included
+ expect(result.to).toEqual(["user1@example.com", "user3@example.com"]);
+ });
+
+ it("should handle all UIDs failing to resolve", async () => {
+ const mockGetAll = jest
+ .fn()
+ .mockResolvedValue([
+ createMockDocSnapshot("uid1", null, false),
+ createMockDocSnapshot("uid2", null, false),
+ ]);
+
+ const mockDb = {
+ getAll: mockGetAll,
+ collection: jest.fn().mockReturnValue({
+ doc: jest.fn().mockImplementation((uid) => ({ id: uid })),
+ }),
+ };
+
+ setDependencies(mockDb, null);
+
+ const payload = {
+ toUids: ["uid1", "uid2"],
+ message: {
+ subject: "Test Subject",
+ text: "Test text",
+ },
+ };
+
+ const result = await preparePayload(payload);
+
+ // No emails resolved, so to should be empty
+ expect(result.to).toEqual([]);
+ });
+
+ it("should resolve UIDs across to, cc, and bcc", async () => {
+ const mockGetAll = jest
+ .fn()
+ .mockResolvedValue([
+ createMockDocSnapshot("uid1", "to@example.com"),
+ createMockDocSnapshot("uid2", "cc@example.com"),
+ createMockDocSnapshot("uid3", "bcc@example.com"),
+ ]);
+
+ const mockDb = {
+ getAll: mockGetAll,
+ collection: jest.fn().mockReturnValue({
+ doc: jest.fn().mockImplementation((uid) => ({ id: uid })),
+ }),
+ };
+
+ setDependencies(mockDb, null);
+
+ const payload = {
+ toUids: ["uid1"],
+ ccUids: ["uid2"],
+ bccUids: ["uid3"],
+ message: {
+ subject: "Test Subject",
+ text: "Test text",
+ },
+ };
+
+ const result = await preparePayload(payload);
+
+ expect(result.to).toEqual(["to@example.com"]);
+ expect(result.cc).toEqual(["cc@example.com"]);
+ expect(result.bcc).toEqual(["bcc@example.com"]);
+ });
+
+ it("should combine direct emails with resolved UIDs", async () => {
+ const mockGetAll = jest
+ .fn()
+ .mockResolvedValue([
+ createMockDocSnapshot("uid1", "resolved@example.com"),
+ ]);
+
+ const mockDb = {
+ getAll: mockGetAll,
+ collection: jest.fn().mockReturnValue({
+ doc: jest.fn().mockImplementation((uid) => ({ id: uid })),
+ }),
+ };
+
+ setDependencies(mockDb, null);
+
+ const payload = {
+ to: "direct@example.com",
+ toUids: ["uid1"],
+ message: {
+ subject: "Test Subject",
+ text: "Test text",
+ },
+ };
+
+ const result = await preparePayload(payload);
+
+ expect(result.to).toEqual(["direct@example.com", "resolved@example.com"]);
+ });
+});
diff --git a/firestore-send-email/functions/__tests__/helpers.test.ts b/firestore-send-email/functions/__tests__/transport.test.ts
similarity index 99%
rename from firestore-send-email/functions/__tests__/helpers.test.ts
rename to firestore-send-email/functions/__tests__/transport.test.ts
index 817b5d897..725f14fd1 100644
--- a/firestore-send-email/functions/__tests__/helpers.test.ts
+++ b/firestore-send-email/functions/__tests__/transport.test.ts
@@ -1,7 +1,7 @@
import Mail = require("nodemailer/lib/mailer");
const { logger } = require("firebase-functions");
-import { setSmtpCredentials, isSendGrid } from "../src/helpers";
+import { setSmtpCredentials, isSendGrid } from "../src/transport";
import { AuthenticatonType, Config } from "../src/types";
const consoleLogSpy = jest.spyOn(logger, "warn").mockImplementation();
diff --git a/firestore-send-email/functions/__tests__/ttl.test.ts b/firestore-send-email/functions/__tests__/ttl.test.ts
new file mode 100644
index 000000000..5ab116832
--- /dev/null
+++ b/firestore-send-email/functions/__tests__/ttl.test.ts
@@ -0,0 +1,94 @@
+// Mock Timestamp class that mimics firebase-admin Timestamp
+class MockTimestamp {
+ private _date: Date;
+
+ constructor(seconds: number, nanoseconds: number) {
+ this._date = new Date(seconds * 1000 + nanoseconds / 1000000);
+ }
+
+ static fromDate(date: Date): MockTimestamp {
+ return new MockTimestamp(Math.floor(date.getTime() / 1000), 0);
+ }
+
+ toDate(): Date {
+ return this._date;
+ }
+}
+
+// Mock firebase-admin/firestore module
+jest.mock("firebase-admin/firestore", () => ({
+ Timestamp: MockTimestamp,
+}));
+
+import { calculateExpireAt } from "../src/ttl";
+import { Timestamp } from "firebase-admin/firestore";
+
+describe("calculateExpireAt", () => {
+ const baseDate = new Date("2024-01-15T12:00:00Z");
+
+ test("calculates hour expiration correctly", () => {
+ const baseTimestamp = Timestamp.fromDate(baseDate);
+ const result = calculateExpireAt(baseTimestamp, "hour", 2);
+ const expected = new Date("2024-01-15T14:00:00Z");
+ expect(result.toDate()).toEqual(expected);
+ });
+
+ test("calculates day expiration correctly", () => {
+ const baseTimestamp = Timestamp.fromDate(baseDate);
+ const result = calculateExpireAt(baseTimestamp, "day", 3);
+ const expected = new Date("2024-01-18T12:00:00Z");
+ expect(result.toDate()).toEqual(expected);
+ });
+
+ test("calculates week expiration correctly", () => {
+ const baseTimestamp = Timestamp.fromDate(baseDate);
+ const result = calculateExpireAt(baseTimestamp, "week", 2);
+ const expected = new Date("2024-01-29T12:00:00Z");
+ expect(result.toDate()).toEqual(expected);
+ });
+
+ test("calculates month expiration correctly", () => {
+ const baseTimestamp = Timestamp.fromDate(baseDate);
+ const result = calculateExpireAt(baseTimestamp, "month", 1);
+ const expected = new Date("2024-02-15T12:00:00Z");
+ expect(result.toDate()).toEqual(expected);
+ });
+
+ test("calculates year expiration correctly", () => {
+ const baseTimestamp = Timestamp.fromDate(baseDate);
+ const result = calculateExpireAt(baseTimestamp, "year", 1);
+ const expected = new Date("2025-01-15T12:00:00Z");
+ expect(result.toDate()).toEqual(expected);
+ });
+
+ test("throws error for unknown TTLExpireType", () => {
+ const baseTimestamp = Timestamp.fromDate(baseDate);
+ expect(() => calculateExpireAt(baseTimestamp, "invalid", 1)).toThrow(
+ "Unknown TTLExpireType: invalid"
+ );
+ });
+
+ test("handles zero value", () => {
+ const baseTimestamp = Timestamp.fromDate(baseDate);
+ const result = calculateExpireAt(baseTimestamp, "day", 0);
+ expect(result.toDate()).toEqual(baseDate);
+ });
+
+ test("handles large values", () => {
+ const baseTimestamp = Timestamp.fromDate(baseDate);
+ const result = calculateExpireAt(baseTimestamp, "day", 365);
+ // 2024 is a leap year (366 days), so 365 days from Jan 15 is Jan 14 next year
+ const expected = new Date("2025-01-14T12:00:00Z");
+ expect(result.toDate()).toEqual(expected);
+ });
+
+ test("handles month boundary correctly", () => {
+ // Jan 31 + 1 month - JavaScript Date overflows Feb
+ const jan31 = Timestamp.fromDate(new Date("2024-01-31T12:00:00Z"));
+ const result = calculateExpireAt(jan31, "month", 1);
+ // For 2024 (leap year), Jan 31 + 1 month = March 2 (Feb has 29 days, 31-29=2)
+ const resultDate = result.toDate();
+ expect(resultDate.getMonth()).toBe(2); // March (0-indexed)
+ expect(resultDate.getDate()).toBe(2);
+ });
+});
diff --git a/firestore-send-email/functions/__tests__/validation.test.ts b/firestore-send-email/functions/__tests__/validation.test.ts
index 1d74fca27..c71e38ec0 100644
--- a/firestore-send-email/functions/__tests__/validation.test.ts
+++ b/firestore-send-email/functions/__tests__/validation.test.ts
@@ -1,4 +1,8 @@
-import { validatePayload, ValidationError } from "../src/validation";
+import {
+ validatePayload,
+ validatePreparedPayload,
+ ValidationError,
+} from "../src/validation";
describe("validatePayload", () => {
describe("valid payloads", () => {
@@ -283,6 +287,24 @@ describe("validatePayload", () => {
);
});
+ it("should throw ValidationError for single attachments not in an array", () => {
+ const invalidPayload = {
+ to: "test@example.com",
+ message: {
+ subject: "Test Subject",
+ text: "Test message",
+ attachments: {
+ filename: "test.txt",
+ content: "test",
+ },
+ },
+ };
+ expect(() => validatePayload(invalidPayload)).toThrow(ValidationError);
+ expect(() => validatePayload(invalidPayload)).toThrow(
+ "Invalid message configuration: Field 'message.attachments' must be an array"
+ );
+ });
+
it("should throw ValidationError for invalid field types", () => {
const invalidPayload = {
to: 123,
@@ -621,3 +643,183 @@ describe("validatePayload", () => {
});
});
});
+
+describe("validatePreparedPayload", () => {
+ describe("valid prepared payloads", () => {
+ test("should accept valid prepared payload with arrays", () => {
+ const payload = {
+ to: ["test@example.com"],
+ cc: [],
+ bcc: [],
+ message: { subject: "Test", text: "Hello" },
+ };
+ expect(() => validatePreparedPayload(payload)).not.toThrow();
+ });
+
+ test("should accept prepared payload with empty recipient arrays", () => {
+ const payload = {
+ to: [],
+ cc: [],
+ bcc: [],
+ message: { subject: "Test", text: "Hello" },
+ };
+ expect(() => validatePreparedPayload(payload)).not.toThrow();
+ });
+
+ test("should accept prepared payload with attachments array", () => {
+ const payload = {
+ to: ["test@example.com"],
+ cc: [],
+ bcc: [],
+ message: {
+ subject: "Test",
+ text: "Hello",
+ attachments: [{ filename: "test.txt", content: "test" }],
+ },
+ };
+ expect(() => validatePreparedPayload(payload)).not.toThrow();
+ });
+
+ test("should accept prepared payload with sendGrid config", () => {
+ const payload = {
+ to: ["test@example.com"],
+ cc: [],
+ bcc: [],
+ sendGrid: {
+ templateId: "d-template-id",
+ dynamicTemplateData: { name: "Test" },
+ },
+ };
+ expect(() => validatePreparedPayload(payload)).not.toThrow();
+ });
+ });
+
+ describe("invalid prepared payloads", () => {
+ test("should reject non-array to field", () => {
+ const payload = {
+ to: "test@example.com",
+ cc: [],
+ bcc: [],
+ };
+ expect(() => validatePreparedPayload(payload)).toThrow(ValidationError);
+ expect(() => validatePreparedPayload(payload)).toThrow(
+ "Field 'to' must be an array"
+ );
+ });
+
+ test("should reject non-array cc field", () => {
+ const payload = {
+ to: [],
+ cc: "test@example.com",
+ bcc: [],
+ };
+ expect(() => validatePreparedPayload(payload)).toThrow(ValidationError);
+ expect(() => validatePreparedPayload(payload)).toThrow(
+ "Field 'cc' must be an array"
+ );
+ });
+
+ test("should reject non-array attachments", () => {
+ const payload = {
+ to: ["test@example.com"],
+ cc: [],
+ bcc: [],
+ message: {
+ subject: "Test",
+ text: "Hello",
+ attachments: { filename: "test.txt" },
+ },
+ };
+ expect(() => validatePreparedPayload(payload)).toThrow(ValidationError);
+ expect(() => validatePreparedPayload(payload)).toThrow(
+ "Field 'message.attachments' must be an array"
+ );
+ });
+ });
+});
+
+describe("validatePayload recipient edge cases", () => {
+ test("should accept valid email address", () => {
+ const payload = {
+ to: "valid@example.com",
+ message: {
+ subject: "Test",
+ text: "Hello",
+ },
+ };
+ expect(() => validatePayload(payload)).not.toThrow();
+ });
+
+ test("should accept email array with valid addresses", () => {
+ const payload = {
+ to: ["valid1@example.com", "valid2@example.com"],
+ message: {
+ subject: "Test",
+ text: "Hello",
+ },
+ };
+ expect(() => validatePayload(payload)).not.toThrow();
+ });
+
+ test("should reject payload with no recipients at all", () => {
+ const payload = {
+ message: {
+ subject: "Test",
+ text: "Hello",
+ },
+ };
+ expect(() => validatePayload(payload)).toThrow(ValidationError);
+ expect(() => validatePayload(payload)).toThrow(
+ "Email must have at least one recipient"
+ );
+ });
+
+ test("should reject payload with empty recipient arrays", () => {
+ const payload = {
+ to: [],
+ cc: [],
+ bcc: [],
+ message: {
+ subject: "Test",
+ text: "Hello",
+ },
+ };
+ expect(() => validatePayload(payload)).toThrow(ValidationError);
+ expect(() => validatePayload(payload)).toThrow(
+ "Email must have at least one recipient"
+ );
+ });
+
+ test("should accept payload with only cc recipient", () => {
+ const payload = {
+ cc: "cc@example.com",
+ message: {
+ subject: "Test",
+ text: "Hello",
+ },
+ };
+ expect(() => validatePayload(payload)).not.toThrow();
+ });
+
+ test("should accept payload with only bcc recipient", () => {
+ const payload = {
+ bcc: "bcc@example.com",
+ message: {
+ subject: "Test",
+ text: "Hello",
+ },
+ };
+ expect(() => validatePayload(payload)).not.toThrow();
+ });
+
+ test("should accept payload with toUids instead of direct email", () => {
+ const payload = {
+ toUids: ["uid1", "uid2"],
+ message: {
+ subject: "Test",
+ text: "Hello",
+ },
+ };
+ expect(() => validatePayload(payload)).not.toThrow();
+ });
+});
diff --git a/firestore-send-email/functions/package-lock.json b/firestore-send-email/functions/package-lock.json
index 94e91b416..5fd6e15cc 100644
--- a/firestore-send-email/functions/package-lock.json
+++ b/firestore-send-email/functions/package-lock.json
@@ -14,7 +14,7 @@
"firebase-admin": "^13.2.0",
"firebase-functions": "^6.3.2",
"handlebars": "^4.5.3",
- "nodemailer": "^6.9.13",
+ "nodemailer": "^7.0.12",
"rimraf": "^2.6.3",
"smtp-server": "^3.13.4",
"typescript": "^5.7.3",
@@ -1514,22 +1514,6 @@
"form-data": "^2.5.0"
}
},
- "node_modules/@types/request/node_modules/form-data": {
- "version": "2.5.3",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.3.tgz",
- "integrity": "sha512-XHIrMD0NpDrNM/Ckf7XJiBbLl57KEhT3+i3yY+eWm+cqYZJQTZrKo8Y8AWKnuV5GT4scfuUGt9LzNoIx3dU1nQ==",
- "optional": true,
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "es-set-tostringtag": "^2.1.0",
- "mime-types": "^2.1.35",
- "safe-buffer": "^5.2.1"
- },
- "engines": {
- "node": ">= 0.12"
- }
- },
"node_modules/@types/send": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
@@ -1698,15 +1682,32 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
- "version": "1.9.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
- "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+ "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
+ "license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
- "form-data": "^4.0.0",
+ "form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
+ "node_modules/axios/node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -1858,32 +1859,63 @@
}
},
"node_modules/body-parser": {
- "version": "1.20.3",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
- "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
+ "version": "1.20.4",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
+ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+ "license": "MIT",
"dependencies": {
- "bytes": "3.1.2",
+ "bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
- "destroy": "1.2.0",
- "http-errors": "2.0.0",
- "iconv-lite": "0.4.24",
- "on-finished": "2.4.1",
- "qs": "6.13.0",
- "raw-body": "2.5.2",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.14.0",
+ "raw-body": "~2.5.3",
"type-is": "~1.6.18",
- "unpipe": "1.0.0"
+ "unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/body-parser/node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -1969,6 +2001,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
"engines": {
"node": ">= 0.8"
}
@@ -1989,6 +2022,7 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
@@ -2601,38 +2635,39 @@
}
},
"node_modules/express": {
- "version": "4.21.2",
- "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
- "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
- "body-parser": "1.20.3",
- "content-disposition": "0.5.4",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
"content-type": "~1.0.4",
- "cookie": "0.7.1",
- "cookie-signature": "1.0.6",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
- "finalhandler": "1.3.1",
- "fresh": "0.5.2",
- "http-errors": "2.0.0",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
- "on-finished": "2.4.1",
+ "on-finished": "~2.4.1",
"parseurl": "~1.3.3",
- "path-to-regexp": "0.1.12",
+ "path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
- "qs": "6.13.0",
+ "qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
- "send": "0.19.0",
- "serve-static": "1.16.2",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
- "statuses": "2.0.1",
+ "statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
@@ -2845,17 +2880,21 @@
}
},
"node_modules/form-data": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
- "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
+ "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
+ "license": "MIT",
+ "optional": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
- "mime-types": "^2.1.12"
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.35",
+ "safe-buffer": "^5.2.1"
},
"engines": {
- "node": ">= 6"
+ "node": ">= 0.12"
}
},
"node_modules/forwarded": {
@@ -3341,6 +3380,7 @@
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
@@ -4161,10 +4201,11 @@
"dev": true
},
"node_modules/js-yaml": {
- "version": "3.14.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
- "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "version": "3.14.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
+ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
@@ -4243,11 +4284,12 @@
}
},
"node_modules/jsonwebtoken/node_modules/jws": {
- "version": "3.2.2",
- "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
- "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
+ "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
+ "license": "MIT",
"dependencies": {
- "jwa": "^1.4.1",
+ "jwa": "^1.4.2",
"safe-buffer": "^5.0.1"
}
},
@@ -4315,11 +4357,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/jws": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
- "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
"dependencies": {
- "jwa": "^2.0.0",
+ "jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
@@ -4703,9 +4746,10 @@
}
},
"node_modules/node-forge": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
- "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
+ "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
+ "license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
@@ -4723,9 +4767,10 @@
"dev": true
},
"node_modules/nodemailer": {
- "version": "6.10.1",
- "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
- "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
+ "version": "7.0.12",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz",
+ "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==",
+ "license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
@@ -4772,6 +4817,7 @@
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -5082,11 +5128,12 @@
]
},
"node_modules/qs": {
- "version": "6.13.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
- "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
+ "version": "6.14.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+ "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+ "license": "BSD-3-Clause",
"dependencies": {
- "side-channel": "^1.0.6"
+ "side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
@@ -5110,15 +5157,45 @@
}
},
"node_modules/raw-body": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
- "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
"dependencies": {
- "bytes": "3.1.2",
- "http-errors": "2.0.0",
- "iconv-lite": "0.4.24",
- "unpipe": "1.0.0"
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/raw-body/node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
},
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body/node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
"engines": {
"node": ">= 0.8"
}
@@ -5267,7 +5344,8 @@
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
- "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
},
"node_modules/semver": {
"version": "6.3.1",
@@ -5381,6 +5459,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
@@ -5399,6 +5478,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
@@ -5414,6 +5494,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -5431,6 +5512,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
@@ -5467,23 +5549,25 @@
}
},
"node_modules/smtp-server": {
- "version": "3.13.6",
- "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.13.6.tgz",
- "integrity": "sha512-dqbSPKn3PCq3Gp5hxBM99u7PET7cQSAWrauhtArJbc+zrf5xNEOjm9+Ob3lySySrRoIEvNE0dz+w2H/xWFJNRw==",
+ "version": "3.18.0",
+ "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.18.0.tgz",
+ "integrity": "sha512-xINTnh0H8JDAKOAGSnFX8mgXB/L4Oz8dG4P0EgKAzJEszngxEEx4vOys+yNpsUc6yIyTKS8m2BcIffq4Htma/w==",
+ "license": "MIT-0",
"dependencies": {
"base32.js": "0.1.0",
"ipv6-normalize": "1.0.1",
- "nodemailer": "6.9.15",
+ "nodemailer": "7.0.11",
"punycode.js": "2.3.1"
},
"engines": {
- "node": ">=12.0.0"
+ "node": ">=18.18.0"
}
},
"node_modules/smtp-server/node_modules/nodemailer": {
- "version": "6.9.15",
- "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.15.tgz",
- "integrity": "sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==",
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz",
+ "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==",
+ "license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
diff --git a/firestore-send-email/functions/package.json b/firestore-send-email/functions/package.json
index f2eae28d6..6fb680744 100644
--- a/firestore-send-email/functions/package.json
+++ b/firestore-send-email/functions/package.json
@@ -11,7 +11,9 @@
"local:emulator": "cd ../../_emulator && firebase emulators:start -P demo-test",
"test": "cd ../../_emulator && firebase emulators:exec --only extensions jest -P demo-test",
"testIfEmulatorRunning": "wait-on tcp:4001 && jest",
+ "testIfEmulatorRunning:file": "wait-on tcp:4001 && jest",
"test:local": "concurrently --kill-others \"npm run local:emulator\" \"npm run testIfEmulatorRunning\"",
+ "test:local:file": "concurrently --kill-others \"npm run local:emulator\" \"npm run testIfEmulatorRunning:file --\"",
"test:watch": "concurrently \"npm run local:emulator\" \"jest --watch\"",
"test:coverage": "concurrently --kill-others \"npm run local:emulator\" \"wait-on tcp:4001 && jest --coverage\"",
"test:e2e:sendgrid": "cd ../../_emulator && firebase emulators:exec --project=demo-test \" cd ../firestore-send-email/functions && E2E_SENDGRID=true jest __tests__/e2e/sendgrid.test.ts\"",
@@ -28,7 +30,7 @@
"firebase-admin": "^13.2.0",
"firebase-functions": "^6.3.2",
"handlebars": "^4.5.3",
- "nodemailer": "^6.9.13",
+ "nodemailer": "^7.0.12",
"rimraf": "^2.6.3",
"smtp-server": "^3.13.4",
"typescript": "^5.7.3",
diff --git a/firestore-send-email/functions/src/delivery.ts b/firestore-send-email/functions/src/delivery.ts
new file mode 100644
index 000000000..c3da519ba
--- /dev/null
+++ b/firestore-send-email/functions/src/delivery.ts
@@ -0,0 +1,107 @@
+/**
+ * Delivery module - extracted for testability.
+ * Contains the core email delivery logic that can be tested
+ * with injected dependencies.
+ */
+
+import { DocumentReference } from "firebase-admin/firestore";
+import * as nodemailer from "nodemailer";
+import * as logs from "./logs";
+import config from "./config";
+import { preparePayload } from "./prepare-payload";
+import { ExtendedSendMailOptions } from "./types";
+
+export interface DeliveryDependencies {
+ transport: nodemailer.Transporter;
+}
+
+export interface SendMailInfoLike {
+ messageId: string | null;
+ sendgridQueueId?: string | null;
+ accepted: string[];
+ rejected: string[];
+ pending: string[];
+ response: string | null;
+}
+
+export interface DeliveryResult {
+ success: boolean;
+ info?: SendMailInfoLike;
+ error?: string;
+ skipped?: boolean;
+}
+
+/**
+ * Prepares and delivers an email from a Firestore document.
+ * Returns the result without updating Firestore (caller handles that).
+ *
+ * @param ref - Firestore document reference
+ * @param deps - Dependencies (transport) for sending email
+ * @returns DeliveryResult indicating success/failure and info
+ */
+export async function deliverEmail(
+ ref: DocumentReference,
+ deps: DeliveryDependencies
+): Promise {
+ // Fetch the Firestore document
+ const snapshot = await ref.get();
+ if (!snapshot.exists) {
+ return { success: false, skipped: true, error: "Document does not exist" };
+ }
+
+ let payload = snapshot.data();
+
+ // Only attempt delivery if the payload is still in the "PROCESSING" state
+ if (!payload.delivery || payload.delivery.state !== "PROCESSING") {
+ return { success: false, skipped: true, error: "Not in PROCESSING state" };
+ }
+
+ logs.attemptingDelivery(ref);
+
+ try {
+ // Prepare the payload for delivery (e.g., formatting recipients, templates)
+ payload = await preparePayload(payload);
+
+ // Validate that there is at least one recipient (to, cc, or bcc)
+ if (!payload.to.length && !payload.cc.length && !payload.bcc.length) {
+ throw new Error(
+ "Failed to deliver email. Expected at least 1 recipient."
+ );
+ }
+
+ 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,
+ headers: payload?.headers,
+ attachments: payload.message?.attachments,
+ categories: payload.categories,
+ templateId: payload.sendGrid?.templateId,
+ dynamicTemplateData: payload.sendGrid?.dynamicTemplateData,
+ mailSettings: payload.sendGrid?.mailSettings,
+ };
+
+ logs.info("Sending via transport.sendMail()", { mailOptions });
+ const result = (await deps.transport.sendMail(mailOptions)) as any;
+
+ const info: SendMailInfoLike = {
+ messageId: result.messageId || null,
+ sendgridQueueId: result.queueId || null,
+ accepted: result.accepted || [],
+ rejected: result.rejected || [],
+ pending: result.pending || [],
+ response: result.response || null,
+ };
+
+ logs.delivered(ref, info);
+ return { success: true, info };
+ } catch (e) {
+ logs.deliveryError(ref, e);
+ return { success: false, error: e.toString() };
+ }
+}
diff --git a/firestore-send-email/functions/src/index.ts b/firestore-send-email/functions/src/index.ts
index 1611a051e..09147bc2a 100644
--- a/firestore-send-email/functions/src/index.ts
+++ b/firestore-send-email/functions/src/index.ts
@@ -30,11 +30,13 @@ import * as nodemailer from "nodemailer";
import * as logs from "./logs";
import config from "./config";
import Templates from "./templates";
-import { Delivery, QueuePayload, ExtendedSendMailOptions } from "./types";
-import { isSendGrid, setSmtpCredentials } from "./helpers";
+import { Delivery, QueuePayload } from "./types";
+import { isSendGrid, setSmtpCredentials } from "./transport";
import * as events from "./events";
import { SendGridTransport } from "./nodemailer-sendgrid";
-import { preparePayload, setDependencies } from "./prepare-payload";
+import { setDependencies } from "./prepare-payload";
+import { deliverEmail } from "./delivery";
+import { getExpireAt } from "./ttl";
logs.init();
@@ -43,15 +45,6 @@ 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.
*/
@@ -93,124 +86,35 @@ async function transportLayer() {
return setSmtpCredentials(config);
}
-function validateFieldArray(field: string, array?: string[]) {
- if (!Array.isArray(array)) {
- throw new Error(`Invalid field "${field}". Expected an array of strings.`);
- }
-
- if (array.find((item) => typeof item !== "string")) {
- throw new Error(`Invalid field "${field}". Expected an array of strings.`);
- }
-}
-
-function getExpireAt(startTime: Timestamp) {
- const now = startTime.toDate();
- const value = config.TTLExpireValue;
- switch (config.TTLExpireType) {
- case "hour":
- now.setHours(now.getHours() + value);
- break;
- case "day":
- now.setDate(now.getDate() + value);
- break;
- case "week":
- now.setDate(now.getDate() + value * 7);
- break;
- case "month":
- now.setMonth(now.getMonth() + value);
- break;
- case "year":
- now.setFullYear(now.getFullYear() + value);
- break;
- default:
- // Optionally handle unknown types
- throw new Error(`Unknown TTLExpireType: ${config.TTLExpireType}`);
- }
- return Timestamp.fromDate(now);
-}
-
async function deliver(ref: DocumentReference): Promise {
- // Fetch the Firestore document
- const snapshot = await ref.get();
- if (!snapshot.exists) {
- return;
- }
-
- let payload = snapshot.data();
+ const result = await deliverEmail(ref, { transport });
- // Only attempt delivery if the payload is still in the "PROCESSING" state
- if (!payload.delivery || payload.delivery.state !== "PROCESSING") {
+ // Skip Firestore update if document was skipped (doesn't exist or not in PROCESSING state)
+ if (result.skipped) {
return;
}
- logs.attemptingDelivery(ref);
-
// Prepare the Firestore document for delivery updates
- const update = {
+ const update: Record = {
"delivery.attempts": FieldValue.increment(1),
"delivery.endTime": FieldValue.serverTimestamp(),
"delivery.error": null,
"delivery.leaseExpireTime": null,
+ "delivery.state": result.success ? "SUCCESS" : "ERROR",
};
- try {
- // Prepare the payload for delivery (e.g., formatting recipients, templates)
- payload = await preparePayload(payload);
-
- // Validate that there is at least one recipient (to, cc, or bcc)
- if (!payload.to.length && !payload.cc.length && !payload.bcc.length) {
- throw new Error(
- "Failed to deliver email. Expected at least 1 recipient."
- );
- }
-
- 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,
- headers: payload?.headers,
- attachments: payload.message?.attachments,
- categories: payload.categories,
- templateId: payload.sendGrid?.templateId,
- dynamicTemplateData: payload.sendGrid?.dynamicTemplateData,
- mailSettings: payload.sendGrid?.mailSettings,
- };
-
- logs.info("Sending via transport.sendMail()", { mailOptions });
- const result = (await transport.sendMail(mailOptions)) as any;
-
- const info: SendMailInfoLike = {
- messageId: result.messageId || null,
- sendgridQueueId: result.queueId || null,
- accepted: result.accepted || [],
- rejected: result.rejected || [],
- pending: result.pending || [],
- response: result.response || null,
- };
-
- // Update Firestore document to indicate success
- update["delivery.state"] = "SUCCESS";
- update["delivery.info"] = info;
-
- logs.delivered(ref, info);
- } catch (e) {
- // Update Firestore document to indicate failure
- update["delivery.state"] = "ERROR";
- update["delivery.error"] = e.toString();
- logs.deliveryError(ref, e);
+ if (result.success && result.info) {
+ update["delivery.info"] = result.info;
+ } else if (!result.success && result.error) {
+ update["delivery.error"] = result.error;
}
// Update the Firestore document transactionally to allow retries (#48)
+ // We could check state here is still PROCESSING, but we don't
+ // since the email sending will have been attempted regardless of what the
+ // delivery state was at that point, so we just update the state to reflect
+ // the result of the last attempt so as to not potentially cause duplicate sends.
return db.runTransaction((transaction) => {
- // We could check state here is still PROCESSING, but we don't
- // since the email sending will have been attempted regardless of what the
- // delivery state was at that point, so we just update the state to reflect
- // the result of the last attempt so as to not potentially cause duplicate sends.
transaction.update(ref, update);
return Promise.resolve();
});
@@ -227,22 +131,8 @@ 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() as QueuePayload;
- // The email has already been delivered, so we don't need to do anything.
- if (
- payloadAfter &&
- payloadAfter.delivery &&
- payloadAfter.delivery.state === "SUCCESS"
- ) {
- return;
- }
-
- // The email has previously failed to be delivered, so we can't do anything.
- if (
- payloadAfter &&
- payloadAfter.delivery &&
- payloadAfter.delivery.state === "ERROR"
- ) {
+ const state = (change.after.data() as QueuePayload)?.delivery?.state;
+ if (state === "SUCCESS" || state === "ERROR") {
return;
}
}
diff --git a/firestore-send-email/functions/src/prepare-payload.ts b/firestore-send-email/functions/src/prepare-payload.ts
index 5574c732d..554104b15 100644
--- a/firestore-send-email/functions/src/prepare-payload.ts
+++ b/firestore-send-email/functions/src/prepare-payload.ts
@@ -1,12 +1,7 @@
import { DocumentData } from "firebase-admin/firestore";
-import {
- validatePayload,
- attachmentSchema,
- attachmentsSchema,
-} from "./validation";
+import { validatePayload, validatePreparedPayload } from "./validation";
import * as logs from "./logs";
import config from "./config";
-import { z } from "zod";
let db: any;
let templates: any;
@@ -16,15 +11,22 @@ export function setDependencies(database: any, templatesInstance: any) {
templates = templatesInstance;
}
-function validateFieldArray(field: string, array?: string[]) {
- if (!Array.isArray(array)) {
- throw new Error(`Invalid field "${field}". Expected an array of strings.`);
- }
-
- if (array.find((item) => typeof item !== "string")) {
- throw new Error(`Invalid field "${field}". Expected an array of strings.`);
- }
-}
+/**
+ * Normalizes recipient field to an array of strings.
+ */
+const normalizeRecipients = (value: string | string[] | undefined): string[] =>
+ typeof value === "string" ? [value] : value || [];
+
+/**
+ * Resolves UIDs to email addresses using a pre-fetched email map.
+ */
+const resolveUidsToEmails = (
+ uids: string[] | undefined,
+ emailMap: Record
+): string[] =>
+ (uids || [])
+ .map((uid) => emailMap[uid])
+ .filter((email): email is string => !!email);
export async function preparePayload(
payload: DocumentData
@@ -41,72 +43,36 @@ export async function preparePayload(
const templateRender = await templates.render(template.name, template.data);
const mergeMessage = payload.message || {};
- let attachments = attachmentsSchema.parse(
- templateRender.attachments
- ? templateRender.attachments
- : mergeMessage.attachments
+ const attachments = templateRender.attachments
+ ? templateRender.attachments
+ : mergeMessage.attachments;
+
+ // Convert null to undefined so it doesn't overwrite existing values
+ const handleTemplateValue = (value: any) =>
+ value === null ? undefined : value;
+
+ const templateContent = Object.fromEntries(
+ Object.entries({
+ subject: handleTemplateValue(templateRender.subject),
+ html: handleTemplateValue(templateRender.html),
+ text: handleTemplateValue(templateRender.text),
+ amp: handleTemplateValue(templateRender.amp),
+ attachments: attachments || [],
+ }).filter(([_, v]) => v !== undefined)
);
- const handleTemplateValue = (value: any) => {
- if (value === null) {
- return undefined;
- }
- if (value === "") {
- return "";
- }
- if (value === undefined) {
- return undefined;
- }
- return value || undefined;
- };
-
- const templateContent = {
- subject: handleTemplateValue(templateRender.subject),
- html: handleTemplateValue(templateRender.html),
- text: handleTemplateValue(templateRender.text),
- amp: handleTemplateValue(templateRender.amp),
- attachments: attachments || [],
- };
-
- Object.keys(templateContent).forEach((key) => {
- if (templateContent[key] === undefined) {
- delete templateContent[key];
- }
- });
-
payload.message = Object.assign(mergeMessage, templateContent);
}
- let to: string[] = [];
- let cc: string[] = [];
- let bcc: string[] = [];
-
- if (typeof payload.to === "string") {
- to = [payload.to];
- } else if (payload.to) {
- validateFieldArray("to", payload.to);
- to = to.concat(payload.to);
- }
-
- if (typeof payload.cc === "string") {
- cc = [payload.cc];
- } else if (payload.cc) {
- validateFieldArray("cc", payload.cc);
- cc = cc.concat(payload.cc);
- }
-
- if (typeof payload.bcc === "string") {
- bcc = [payload.bcc];
- } else if (payload.bcc) {
- validateFieldArray("bcc", payload.bcc);
- bcc = bcc.concat(payload.bcc);
- }
+ let to = normalizeRecipients(payload.to);
+ let cc = normalizeRecipients(payload.cc);
+ let bcc = normalizeRecipients(payload.bcc);
if (!payload.toUids && !payload.ccUids && !payload.bccUids) {
payload.to = to;
payload.cc = cc;
payload.bcc = bcc;
- return payload;
+ return validatePreparedPayload(payload);
}
if (!config.usersCollection) {
@@ -116,17 +82,14 @@ export async function preparePayload(
let uids: string[] = [];
if (payload.toUids) {
- validateFieldArray("toUids", payload.toUids);
uids = uids.concat(payload.toUids);
}
if (payload.ccUids) {
- validateFieldArray("ccUids", payload.ccUids);
uids = uids.concat(payload.ccUids);
}
if (payload.bccUids) {
- validateFieldArray("bccUids", payload.bccUids);
uids = uids.concat(payload.bccUids);
}
@@ -160,38 +123,9 @@ export async function preparePayload(
logs.missingUids(missingUids);
- if (payload.toUids) {
- payload.toUids.forEach((uid) => {
- const email = toFetch[uid];
- if (email) {
- to.push(email);
- }
- });
- }
-
- payload.to = to;
-
- if (payload.ccUids) {
- payload.ccUids.forEach((uid) => {
- const email = toFetch[uid];
- if (email) {
- cc.push(email);
- }
- });
- }
-
- payload.cc = cc;
-
- if (payload.bccUids) {
- payload.bccUids.forEach((uid) => {
- const email = toFetch[uid];
- if (email) {
- bcc.push(email);
- }
- });
- }
-
- payload.bcc = bcc;
+ payload.to = to.concat(resolveUidsToEmails(payload.toUids, toFetch));
+ payload.cc = cc.concat(resolveUidsToEmails(payload.ccUids, toFetch));
+ payload.bcc = bcc.concat(resolveUidsToEmails(payload.bccUids, toFetch));
- return payload;
+ return validatePreparedPayload(payload);
}
diff --git a/firestore-send-email/functions/src/helpers.ts b/firestore-send-email/functions/src/transport.ts
similarity index 100%
rename from firestore-send-email/functions/src/helpers.ts
rename to firestore-send-email/functions/src/transport.ts
diff --git a/firestore-send-email/functions/src/ttl.ts b/firestore-send-email/functions/src/ttl.ts
new file mode 100644
index 000000000..e635a07c5
--- /dev/null
+++ b/firestore-send-email/functions/src/ttl.ts
@@ -0,0 +1,66 @@
+/**
+ * Copyright 2019 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Timestamp } from "firebase-admin/firestore";
+import config from "./config";
+
+/**
+ * Calculates expiration timestamp based on TTL configuration.
+ * @param startTime - The starting timestamp
+ * @param ttlType - The type of TTL (hour, day, week, month, year)
+ * @param ttlValue - The number of units to add
+ * @returns The calculated expiration timestamp
+ */
+export function calculateExpireAt(
+ startTime: Timestamp,
+ ttlType: string,
+ ttlValue: number
+): Timestamp {
+ const date = startTime.toDate();
+ switch (ttlType) {
+ case "hour":
+ date.setHours(date.getHours() + ttlValue);
+ break;
+ case "day":
+ date.setDate(date.getDate() + ttlValue);
+ break;
+ case "week":
+ date.setDate(date.getDate() + ttlValue * 7);
+ break;
+ case "month":
+ date.setMonth(date.getMonth() + ttlValue);
+ break;
+ case "year":
+ date.setFullYear(date.getFullYear() + ttlValue);
+ break;
+ default:
+ throw new Error(`Unknown TTLExpireType: ${ttlType}`);
+ }
+ return Timestamp.fromDate(date);
+}
+
+/**
+ * Gets the expiration timestamp based on the configured TTL settings.
+ * @param startTime - The starting timestamp
+ * @returns The calculated expiration timestamp
+ */
+export function getExpireAt(startTime: Timestamp): Timestamp {
+ return calculateExpireAt(
+ startTime,
+ config.TTLExpireType,
+ config.TTLExpireValue
+ );
+}
diff --git a/firestore-send-email/functions/src/validation.ts b/firestore-send-email/functions/src/validation.ts
index 0d13af707..cb4d09782 100644
--- a/firestore-send-email/functions/src/validation.ts
+++ b/firestore-send-email/functions/src/validation.ts
@@ -281,19 +281,31 @@ export function validatePayload(payload: any) {
);
}
- if (payload.message && Object.keys(payload.message).length > 0) {
- const messageSchema = payload.template
- ? templateMessageSchema
- : standardMessageSchema;
- validateField(
- payload.message,
- messageSchema,
- "message",
- "message configuration"
- );
+ if (payload.message !== undefined) {
+ // If there's a template or sendGrid, message content is optional (they provide it)
+ if (payload.template || payload.sendGrid) {
+ // Only validate message structure if it has content
+ if (Object.keys(payload.message).length > 0) {
+ validateField(
+ payload.message,
+ templateMessageSchema,
+ "message",
+ "message configuration"
+ );
+ }
+ } else {
+ // No template/sendGrid - message must have subject + content
+ validateField(
+ payload.message,
+ standardMessageSchema,
+ "message",
+ "message configuration"
+ );
+ }
}
const result = payloadSchema.safeParse(payload);
+
if (!result.success) {
throw new ValidationError(formatZodError(result.error));
}
@@ -308,3 +320,52 @@ export function validatePayload(payload: any) {
);
}
}
+
+/**
+ * Schema for the prepared message after transformations.
+ */
+const preparedMessageSchema = z
+ .object({
+ subject: z.string().optional(),
+ text: z.string().nullable().optional(),
+ html: z.string().nullable().optional(),
+ amp: z.string().optional(),
+ attachments: attachmentsSchema,
+ })
+ .passthrough();
+
+/**
+ * Schema for the prepared payload after all transformations.
+ * Recipients are normalized to arrays, attachments validated.
+ */
+export const preparedPayloadSchema = z
+ .object({
+ to: z.array(z.string()).default([]),
+ cc: z.array(z.string()).default([]),
+ bcc: z.array(z.string()).default([]),
+ from: z.string().optional(),
+ replyTo: z.string().optional(),
+ headers: z.record(z.any()).optional(),
+ message: preparedMessageSchema.optional(),
+ template: templateSchema.optional(),
+ sendGrid: sendGridSchema.optional(),
+ categories: z.array(z.string()).optional(),
+ })
+ .passthrough();
+
+export type PreparedPayload = z.infer;
+
+/**
+ * Validates the prepared payload after all transformations.
+ * Called at the end of preparePayload() before returning.
+ * @param payload - The prepared payload to validate
+ * @returns The validated payload
+ * @throws {ValidationError} When validation fails
+ */
+export function validatePreparedPayload(payload: unknown): PreparedPayload {
+ const result = preparedPayloadSchema.safeParse(payload);
+ if (!result.success) {
+ throw new ValidationError(formatZodError(result.error, "prepared payload"));
+ }
+ return result.data;
+}