From b4a9d7aac327b5b9771076ed7856409bc68de58f Mon Sep 17 00:00:00 2001 From: Michal Pech Date: Fri, 27 Feb 2026 13:57:42 +0100 Subject: [PATCH 1/2] feat: Propagate custom_args to sendgrid api --- .../nodemailer-sendgrid/index.test.ts | 28 +++++++++++++++++++ .../functions/__tests__/validation.test.ts | 21 ++++++++++++++ firestore-send-email/functions/src/index.ts | 1 + .../src/nodemailer-sendgrid/index.ts | 3 ++ .../src/nodemailer-sendgrid/types.ts | 2 ++ firestore-send-email/functions/src/types.ts | 2 ++ .../functions/src/validation.ts | 1 + 7 files changed, 58 insertions(+) diff --git a/firestore-send-email/functions/__tests__/nodemailer-sendgrid/index.test.ts b/firestore-send-email/functions/__tests__/nodemailer-sendgrid/index.test.ts index 3ec2c7753..ffe970ff1 100644 --- a/firestore-send-email/functions/__tests__/nodemailer-sendgrid/index.test.ts +++ b/firestore-send-email/functions/__tests__/nodemailer-sendgrid/index.test.ts @@ -408,6 +408,34 @@ describe("SendGridTransport", () => { }); }); + test("send: forwards customArgs 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: "Custom args test", + customArgs: { campaign: "welcome", source: "signup" }, + }), + }; + 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.customArgs).toEqual({ campaign: "welcome", source: "signup" }); + 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 = { diff --git a/firestore-send-email/functions/__tests__/validation.test.ts b/firestore-send-email/functions/__tests__/validation.test.ts index 1d74fca27..0f6ebe5bf 100644 --- a/firestore-send-email/functions/__tests__/validation.test.ts +++ b/firestore-send-email/functions/__tests__/validation.test.ts @@ -212,6 +212,17 @@ describe("validatePayload", () => { }); }); + it("should validate a SendGrid payload with customArgs", () => { + const validPayload = { + to: "test@example.com", + sendGrid: { + templateId: "d-template-id", + customArgs: { campaign: "welcome", source: "signup" }, + }, + }; + expect(() => validatePayload(validPayload)).not.toThrow(); + }); + it("should validate a SendGrid payload with only mailSettings", () => { const validPayload = { to: "test@example.com", @@ -255,6 +266,16 @@ describe("validatePayload", () => { ); }); + it("should throw ValidationError for SendGrid customArgs with non-string values", () => { + const invalidPayload = { + to: "test@example.com", + sendGrid: { + customArgs: { campaign: 123 }, + }, + }; + expect(() => validatePayload(invalidPayload)).toThrow(ValidationError); + }); + it("should throw ValidationError for custom template without name", () => { const invalidPayload = { to: "test@example.com", diff --git a/firestore-send-email/functions/src/index.ts b/firestore-send-email/functions/src/index.ts index 1611a051e..b6ded50ba 100644 --- a/firestore-send-email/functions/src/index.ts +++ b/firestore-send-email/functions/src/index.ts @@ -179,6 +179,7 @@ async function deliver(ref: DocumentReference): Promise { templateId: payload.sendGrid?.templateId, dynamicTemplateData: payload.sendGrid?.dynamicTemplateData, mailSettings: payload.sendGrid?.mailSettings, + customArgs: payload.sendGrid?.customArgs, }; logs.info("Sending via transport.sendMail()", { mailOptions }); diff --git a/firestore-send-email/functions/src/nodemailer-sendgrid/index.ts b/firestore-send-email/functions/src/nodemailer-sendgrid/index.ts index 9d7d24c78..822fe078a 100644 --- a/firestore-send-email/functions/src/nodemailer-sendgrid/index.ts +++ b/firestore-send-email/functions/src/nodemailer-sendgrid/index.ts @@ -152,6 +152,9 @@ export class SendGridTransport { case "mailSettings": msg.mailSettings = source.mailSettings; break; + case "customArgs": + msg.customArgs = source.customArgs; + break; default: msg[key] = source[key]; diff --git a/firestore-send-email/functions/src/nodemailer-sendgrid/types.ts b/firestore-send-email/functions/src/nodemailer-sendgrid/types.ts index f8e6e626c..22ec87aa6 100644 --- a/firestore-send-email/functions/src/nodemailer-sendgrid/types.ts +++ b/firestore-send-email/functions/src/nodemailer-sendgrid/types.ts @@ -46,6 +46,7 @@ export interface MailSource { templateId?: string; dynamicTemplateData?: Record; mailSettings?: Record; + customArgs?: Record; [key: string]: unknown; } @@ -91,5 +92,6 @@ export interface SendGridMessage { templateId?: string; dynamicTemplateData?: Record; mailSettings?: Record; + customArgs?: Record; [key: string]: unknown; } diff --git a/firestore-send-email/functions/src/types.ts b/firestore-send-email/functions/src/types.ts index e03e74e74..c418d842a 100644 --- a/firestore-send-email/functions/src/types.ts +++ b/firestore-send-email/functions/src/types.ts @@ -86,6 +86,7 @@ export interface QueuePayload { templateId?: string; dynamicTemplateData?: { [key: string]: any }; mailSettings?: { [key: string]: any }; + customArgs?: Record; }; to: string[]; toUids?: string[]; @@ -127,4 +128,5 @@ export interface ExtendedSendMailOptions extends nodemailer.SendMailOptions { templateId?: string; dynamicTemplateData?: Record; mailSettings?: Record; + customArgs?: Record; } diff --git a/firestore-send-email/functions/src/validation.ts b/firestore-send-email/functions/src/validation.ts index 0d13af707..cd0588861 100644 --- a/firestore-send-email/functions/src/validation.ts +++ b/firestore-send-email/functions/src/validation.ts @@ -93,6 +93,7 @@ const sendGridSchema = z templateId: z.string().optional(), dynamicTemplateData: z.record(z.any()).optional(), mailSettings: z.record(z.any()).optional(), + customArgs: z.record(z.string()).optional(), }) .refine( (data) => { From 632eb1df01a2d7fadb6bb053fe6c5c4b64d26147 Mon Sep 17 00:00:00 2001 From: Michal Pech Date: Mon, 2 Mar 2026 11:30:19 +0100 Subject: [PATCH 2/2] feat: Propagate ip_pool_name to sendgrid api --- .../nodemailer-sendgrid/index.test.ts | 28 +++++++++++++++++++ .../functions/__tests__/validation.test.ts | 21 ++++++++++++++ firestore-send-email/functions/src/index.ts | 1 + .../src/nodemailer-sendgrid/index.ts | 3 ++ .../src/nodemailer-sendgrid/types.ts | 2 ++ firestore-send-email/functions/src/types.ts | 2 ++ .../functions/src/validation.ts | 1 + 7 files changed, 58 insertions(+) diff --git a/firestore-send-email/functions/__tests__/nodemailer-sendgrid/index.test.ts b/firestore-send-email/functions/__tests__/nodemailer-sendgrid/index.test.ts index ffe970ff1..bab3e3445 100644 --- a/firestore-send-email/functions/__tests__/nodemailer-sendgrid/index.test.ts +++ b/firestore-send-email/functions/__tests__/nodemailer-sendgrid/index.test.ts @@ -436,6 +436,34 @@ describe("SendGridTransport", () => { }); }); + test("send: forwards ipPoolName string", 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: "IP pool test", + ipPoolName: "transactional", + }), + }; + 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.ipPoolName).toEqual("transactional"); + 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 = { diff --git a/firestore-send-email/functions/__tests__/validation.test.ts b/firestore-send-email/functions/__tests__/validation.test.ts index 0f6ebe5bf..e54990b3c 100644 --- a/firestore-send-email/functions/__tests__/validation.test.ts +++ b/firestore-send-email/functions/__tests__/validation.test.ts @@ -223,6 +223,17 @@ describe("validatePayload", () => { expect(() => validatePayload(validPayload)).not.toThrow(); }); + it("should validate a SendGrid payload with ipPoolName", () => { + const validPayload = { + to: "test@example.com", + sendGrid: { + templateId: "d-template-id", + ipPoolName: "transactional", + }, + }; + expect(() => validatePayload(validPayload)).not.toThrow(); + }); + it("should validate a SendGrid payload with only mailSettings", () => { const validPayload = { to: "test@example.com", @@ -276,6 +287,16 @@ describe("validatePayload", () => { expect(() => validatePayload(invalidPayload)).toThrow(ValidationError); }); + it("should throw ValidationError for SendGrid ipPoolName with non-string value", () => { + const invalidPayload = { + to: "test@example.com", + sendGrid: { + ipPoolName: 123, + }, + }; + expect(() => validatePayload(invalidPayload)).toThrow(ValidationError); + }); + it("should throw ValidationError for custom template without name", () => { const invalidPayload = { to: "test@example.com", diff --git a/firestore-send-email/functions/src/index.ts b/firestore-send-email/functions/src/index.ts index b6ded50ba..97cc48e09 100644 --- a/firestore-send-email/functions/src/index.ts +++ b/firestore-send-email/functions/src/index.ts @@ -180,6 +180,7 @@ async function deliver(ref: DocumentReference): Promise { dynamicTemplateData: payload.sendGrid?.dynamicTemplateData, mailSettings: payload.sendGrid?.mailSettings, customArgs: payload.sendGrid?.customArgs, + ipPoolName: payload.sendGrid?.ipPoolName, }; logs.info("Sending via transport.sendMail()", { mailOptions }); diff --git a/firestore-send-email/functions/src/nodemailer-sendgrid/index.ts b/firestore-send-email/functions/src/nodemailer-sendgrid/index.ts index 822fe078a..e02807aa3 100644 --- a/firestore-send-email/functions/src/nodemailer-sendgrid/index.ts +++ b/firestore-send-email/functions/src/nodemailer-sendgrid/index.ts @@ -155,6 +155,9 @@ export class SendGridTransport { case "customArgs": msg.customArgs = source.customArgs; break; + case "ipPoolName": + msg.ipPoolName = source.ipPoolName; + break; default: msg[key] = source[key]; diff --git a/firestore-send-email/functions/src/nodemailer-sendgrid/types.ts b/firestore-send-email/functions/src/nodemailer-sendgrid/types.ts index 22ec87aa6..3f7ed10c5 100644 --- a/firestore-send-email/functions/src/nodemailer-sendgrid/types.ts +++ b/firestore-send-email/functions/src/nodemailer-sendgrid/types.ts @@ -47,6 +47,7 @@ export interface MailSource { dynamicTemplateData?: Record; mailSettings?: Record; customArgs?: Record; + ipPoolName?: string; [key: string]: unknown; } @@ -93,5 +94,6 @@ export interface SendGridMessage { dynamicTemplateData?: Record; mailSettings?: Record; customArgs?: Record; + ipPoolName?: string; [key: string]: unknown; } diff --git a/firestore-send-email/functions/src/types.ts b/firestore-send-email/functions/src/types.ts index c418d842a..1b476fb23 100644 --- a/firestore-send-email/functions/src/types.ts +++ b/firestore-send-email/functions/src/types.ts @@ -87,6 +87,7 @@ export interface QueuePayload { dynamicTemplateData?: { [key: string]: any }; mailSettings?: { [key: string]: any }; customArgs?: Record; + ipPoolName?: string; }; to: string[]; toUids?: string[]; @@ -129,4 +130,5 @@ export interface ExtendedSendMailOptions extends nodemailer.SendMailOptions { dynamicTemplateData?: Record; mailSettings?: Record; customArgs?: Record; + ipPoolName?: string; } diff --git a/firestore-send-email/functions/src/validation.ts b/firestore-send-email/functions/src/validation.ts index cd0588861..98f4e4ca7 100644 --- a/firestore-send-email/functions/src/validation.ts +++ b/firestore-send-email/functions/src/validation.ts @@ -94,6 +94,7 @@ const sendGridSchema = z dynamicTemplateData: z.record(z.any()).optional(), mailSettings: z.record(z.any()).optional(), customArgs: z.record(z.string()).optional(), + ipPoolName: z.string().optional(), }) .refine( (data) => {