From 85f5cc7aa024b6990908f5306b6db0557cc6efd3 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Wed, 2 Sep 2026 13:43:43 +0100 Subject: [PATCH 1/6] fix(firestore-send-email): keep SMTP_PASSWORD available so SendGrid works with AUTH_TYPE=OAuth2 configFromEnv forced smtpPassword to undefined whenever AUTH_TYPE=OAuth2, but transportLayer picks the SendGrid transport purely on the SMTP URI host and passes smtpPassword as the SendGrid API key. With OAuth2 the transport got apiKey: undefined, sgMail.setApiKey was never called, and every send failed. The legacy extension reads SMTP_PASSWORD unconditionally (firestore-send-email/functions/src/config.ts, index.ts:86-90 on master), so SendGrid + OAuth2 worked there. The config now passes the SMTP password secret through for every auth type, and secretParamsForAuthType keeps SMTP_PASSWORD bound under OAuth2 so a future gated deploy binding cannot reintroduce the bug. Fixes #3009 --- kits/firestore-send-email/CHANGELOG.md | 1 + kits/firestore-send-email/src/config.ts | 16 +++++++++---- .../firestore-send-email/tests/config.test.ts | 22 +++++++++++++---- .../tests/helpers.test.ts | 24 ++++++++++++++++++- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/kits/firestore-send-email/CHANGELOG.md b/kits/firestore-send-email/CHANGELOG.md index 711eb60d36..0c49e52aef 100644 --- a/kits/firestore-send-email/CHANGELOG.md +++ b/kits/firestore-send-email/CHANGELOG.md @@ -1 +1,2 @@ - Initial release of kit, see README for differences between the legacy extension and this kit +- SendGrid sends now work with `AUTH_TYPE=OAuth2`: the `SMTP_PASSWORD` secret is no longer dropped from the config under OAuth2, so the SendGrid transport receives its API key diff --git a/kits/firestore-send-email/src/config.ts b/kits/firestore-send-email/src/config.ts index 77059e6e53..55bdd56c02 100644 --- a/kits/firestore-send-email/src/config.ts +++ b/kits/firestore-send-email/src/config.ts @@ -311,7 +311,14 @@ export const secretParams = [ export function secretParamsForAuthType(authType?: string) { switch (authType || AuthenticatonType.UsernamePassword) { case AuthenticatonType.OAuth2: - return [params.clientId, params.clientSecret, params.refreshToken]; + // The SendGrid transport reads SMTP_PASSWORD as its API key regardless + // of auth type, so it must stay bound under OAuth2. + return [ + params.smtpPassword, + params.clientId, + params.clientSecret, + params.refreshToken, + ]; case AuthenticatonType.UsernamePassword: case AuthenticatonType.ApiKey: return [params.smtpPassword]; @@ -336,10 +343,9 @@ export function configFromEnv(): SendEmailConfig { databaseRegion: params.databaseRegion.value(), mailCollection: params.mailCollection.value(), smtpConnectionUri: params.smtpConnectionUri.value(), - smtpPassword: - authType === AuthenticatonType.OAuth2 - ? undefined - : optionalSecret(params.smtpPassword), + // Not gated on auth type: the SendGrid transport reads it as its API key + // even when AUTH_TYPE is OAuth2. + smtpPassword: optionalSecret(params.smtpPassword), defaultFrom: params.defaultFrom.value(), defaultReplyTo: params.defaultReplyTo.value(), usersCollection: params.usersCollection.value(), diff --git a/kits/firestore-send-email/tests/config.test.ts b/kits/firestore-send-email/tests/config.test.ts index 95e28f18be..4ccf1440b3 100644 --- a/kits/firestore-send-email/tests/config.test.ts +++ b/kits/firestore-send-email/tests/config.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; interface StringParamOpts { default?: string; @@ -29,7 +29,7 @@ vi.mock("firebase-functions/params", () => ({ defineString: (name: string, opts?: StringParamOpts) => { stringParamOpts.set(name, opts); return { - value: () => opts?.default ?? "", + value: () => process.env[name] ?? opts?.default ?? "", }; }, defineInt: (_name: string, opts?: { default?: number }) => ({ @@ -60,6 +60,10 @@ import { resolveConfig } from "../src/export-config"; import { AuthenticatonType } from "../src/types"; describe("configFromEnv", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + test("maps params and keeps secret-backed values deferred", () => { const config = configFromEnv(); expect(config.mailCollection).toBe("mail"); @@ -68,6 +72,16 @@ describe("configFromEnv", () => { expect(typeof config.smtpPassword).toBe("object"); expect(config.clientId).toBeUndefined(); }); + + test("keeps the SMTP password for OAuth2 so SendGrid can use it as API key", () => { + vi.stubEnv("AUTH_TYPE", AuthenticatonType.OAuth2); + const config = configFromEnv(); + expect(config.authType).toBe(AuthenticatonType.OAuth2); + expect(typeof config.smtpPassword).toBe("object"); + expect(typeof config.clientId).toBe("object"); + expect(typeof config.clientSecret).toBe("object"); + expect(typeof config.refreshToken).toBe("object"); + }); }); describe("secretParamsForAuthType", () => { @@ -79,12 +93,12 @@ describe("secretParamsForAuthType", () => { ).toEqual(["SMTP_PASSWORD"]); }); - test("binds only OAuth secrets for OAuth2 auth", () => { + test("keeps the SMTP password bound alongside OAuth secrets for OAuth2 auth", () => { expect( secretParamsForAuthType(AuthenticatonType.OAuth2).map( (secret) => (secret as { name: string }).name ) - ).toEqual(["CLIENT_ID", "CLIENT_SECRET", "REFRESH_TOKEN"]); + ).toEqual(["SMTP_PASSWORD", "CLIENT_ID", "CLIENT_SECRET", "REFRESH_TOKEN"]); }); test("uses username/password secret binding by default", () => { diff --git a/kits/firestore-send-email/tests/helpers.test.ts b/kits/firestore-send-email/tests/helpers.test.ts index f3c025eda2..1ea0421224 100644 --- a/kits/firestore-send-email/tests/helpers.test.ts +++ b/kits/firestore-send-email/tests/helpers.test.ts @@ -17,8 +17,15 @@ import { logger } from "firebase-functions"; import Mail from "nodemailer/lib/mailer"; import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("@sendgrid/mail", () => ({ + setApiKey: vi.fn(), + send: vi.fn(), +})); + +import * as sgMail from "@sendgrid/mail"; import type { ResolvedSendEmailConfig } from "../src/export-config"; -import { isSendGrid, setSmtpCredentials } from "../src/helpers"; +import { isSendGrid, setSmtpCredentials, transportLayer } from "../src/helpers"; import { AuthenticatonType } from "../src/types"; const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => undefined); @@ -253,3 +260,18 @@ describe("isSendGrid", () => { expect(isSendGrid(makeConfig({}))).toBe(false); }); }); + +describe("transportLayer", () => { + test("passes the SMTP password to SendGrid as the API key when AUTH_TYPE is OAuth2", async () => { + const transport = await transportLayer( + makeConfig({ + smtpConnectionUri: "smtps://apikey@smtp.sendgrid.net:465", + smtpPassword: "SG.test-key", + authType: AuthenticatonType.OAuth2, + }) + ); + + expect(vi.mocked(sgMail.setApiKey)).toHaveBeenCalledWith("SG.test-key"); + expect(transport).toBeDefined(); + }); +}); From bd86ddd9cbb1cf81dc8a5469ba3cbe3ce76a68f1 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Wed, 2 Sep 2026 13:53:18 +0100 Subject: [PATCH 2/6] test(firestore-send-email): stop ambient shell vars feeding the param mock --- kits/firestore-send-email/tests/config.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/kits/firestore-send-email/tests/config.test.ts b/kits/firestore-send-email/tests/config.test.ts index 4ccf1440b3..5de4aba786 100644 --- a/kits/firestore-send-email/tests/config.test.ts +++ b/kits/firestore-send-email/tests/config.test.ts @@ -26,10 +26,15 @@ const { stringParamOpts } = vi.hoisted(() => ({ })); vi.mock("firebase-functions/params", () => ({ + // Only stubbed names may read the env: ambient shell vars (USER, HOST) + // collide with real param names and would make results machine-dependent. defineString: (name: string, opts?: StringParamOpts) => { stringParamOpts.set(name, opts); return { - value: () => process.env[name] ?? opts?.default ?? "", + value: () => + (name === "AUTH_TYPE" ? process.env[name] : undefined) ?? + opts?.default ?? + "", }; }, defineInt: (_name: string, opts?: { default?: number }) => ({ From 2c2a3258bd01003e7f842d65b9792f58387859d0 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 7 Sep 2026 11:48:40 +0100 Subject: [PATCH 3/6] fix(firestore-send-email): keep the SendGrid client methods reachable @sendgrid/mail exports a MailService instance, so setApiKey and send are prototype methods. Under esModuleInterop the namespace import compiles to __importStar, which copies own properties only, so both were dropped and every SendGrid send threw, at init once an API key was present and at send time otherwise. The legacy extension has the same import line but no esModuleInterop, so it was unaffected. The mocks now expose the module as both the default and the named exports, matching its real shape. The new test runs against lib/ because vitest's transform does not reproduce the tsc emit that causes this. --- kits/firestore-send-email/CHANGELOG.md | 1 + .../src/nodemailer-sendgrid/index.ts | 2 +- .../tests/build-interop.test.ts | 31 +++++++++++++++++++ .../tests/helpers.test.ts | 10 +++--- .../tests/nodemailer-sendgrid.test.ts | 25 +++++++++------ 5 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 kits/firestore-send-email/tests/build-interop.test.ts diff --git a/kits/firestore-send-email/CHANGELOG.md b/kits/firestore-send-email/CHANGELOG.md index 0c49e52aef..156348bc17 100644 --- a/kits/firestore-send-email/CHANGELOG.md +++ b/kits/firestore-send-email/CHANGELOG.md @@ -1,2 +1,3 @@ - Initial release of kit, see README for differences between the legacy extension and this kit - SendGrid sends now work with `AUTH_TYPE=OAuth2`: the `SMTP_PASSWORD` secret is no longer dropped from the config under OAuth2, so the SendGrid transport receives its API key +- SendGrid delivery no longer fails with `sgMail.setApiKey is not a function`: the transport imports `@sendgrid/mail` in a form that survives the compiled output diff --git a/kits/firestore-send-email/src/nodemailer-sendgrid/index.ts b/kits/firestore-send-email/src/nodemailer-sendgrid/index.ts index 94f914baf6..0f83a0f732 100644 --- a/kits/firestore-send-email/src/nodemailer-sendgrid/index.ts +++ b/kits/firestore-send-email/src/nodemailer-sendgrid/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import * as sgMail from "@sendgrid/mail"; +import sgMail from "@sendgrid/mail"; import type { Address, MailSource, diff --git a/kits/firestore-send-email/tests/build-interop.test.ts b/kits/firestore-send-email/tests/build-interop.test.ts new file mode 100644 index 0000000000..d9e7be0821 --- /dev/null +++ b/kits/firestore-send-email/tests/build-interop.test.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2026 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 { createRequire } from "node:module"; +import { describe, expect, test } from "vitest"; + +// The subject is the compiled output, not src: only tsc's esModuleInterop +// helper drops the prototype methods off the instance @sendgrid/mail exports, +// and vitest's own transform does not reproduce that. Needs `npm run build`, +// which CI runs before `npm test`. +const requireBuilt = createRequire(import.meta.url); + +describe("built SendGridTransport", () => { + test("reaches the methods on the @sendgrid/mail instance", () => { + const { SendGridTransport } = requireBuilt("../lib/nodemailer-sendgrid"); + expect(() => new SendGridTransport({ apiKey: "SG.test" })).not.toThrow(); + }); +}); diff --git a/kits/firestore-send-email/tests/helpers.test.ts b/kits/firestore-send-email/tests/helpers.test.ts index 1ea0421224..69c83bca1e 100644 --- a/kits/firestore-send-email/tests/helpers.test.ts +++ b/kits/firestore-send-email/tests/helpers.test.ts @@ -18,10 +18,12 @@ import { logger } from "firebase-functions"; import Mail from "nodemailer/lib/mailer"; import { beforeEach, describe, expect, test, vi } from "vitest"; -vi.mock("@sendgrid/mail", () => ({ - setApiKey: vi.fn(), - send: vi.fn(), -})); +// @sendgrid/mail exports a single MailService instance, so the mock has to be +// reachable as both the default and the named exports. +vi.mock("@sendgrid/mail", () => { + const mail = { setApiKey: vi.fn(), send: vi.fn() }; + return { ...mail, default: mail }; +}); import * as sgMail from "@sendgrid/mail"; import type { ResolvedSendEmailConfig } from "../src/export-config"; diff --git a/kits/firestore-send-email/tests/nodemailer-sendgrid.test.ts b/kits/firestore-send-email/tests/nodemailer-sendgrid.test.ts index 01ebea2d96..12824eecf7 100644 --- a/kits/firestore-send-email/tests/nodemailer-sendgrid.test.ts +++ b/kits/firestore-send-email/tests/nodemailer-sendgrid.test.ts @@ -16,16 +16,21 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -vi.mock("@sendgrid/mail", () => ({ - setApiKey: vi.fn(), - send: vi.fn().mockResolvedValue([ - { - headers: { "x-message-id": "test-message-id" }, - statusCode: 202, - }, - {}, - ]), -})); +// @sendgrid/mail exports a single MailService instance, so the mock has to be +// reachable as both the default and the named exports. +vi.mock("@sendgrid/mail", () => { + const mail = { + setApiKey: vi.fn(), + send: vi.fn().mockResolvedValue([ + { + headers: { "x-message-id": "test-message-id" }, + statusCode: 202, + }, + {}, + ]), + }; + return { ...mail, default: mail }; +}); import * as sgMail from "@sendgrid/mail"; import { SendGridTransport } from "../src/nodemailer-sendgrid"; From 9e87a42fad01273e7a6351ba8e61fad0ac789181 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 7 Sep 2026 12:53:15 +0100 Subject: [PATCH 4/6] docs(firestore-send-email): note that SendGrid needs the real key in SMTP_PASSWORD The OAuth2 setup step told every install to create SMTP_PASSWORD with a placeholder, which leaves SendGrid users failing every send with a 401. --- kits/firestore-send-email/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/kits/firestore-send-email/README.md b/kits/firestore-send-email/README.md index 722146cd36..fa89411050 100644 --- a/kits/firestore-send-email/README.md +++ b/kits/firestore-send-email/README.md @@ -154,7 +154,10 @@ picked up. All four are attached to the function whatever `AUTH_TYPE` is set to, and were optional in the extension. If a secret does not exist, `firebase deploy` prompts you for a value, and fails outright when running non-interactively (CI). On username/password auth create the three OAuth2 secrets with a placeholder -value, and on OAuth2 auth do the same for `SMTP_PASSWORD`. +value. On OAuth2 auth do the same for `SMTP_PASSWORD`, unless you send through +SendGrid: the SendGrid transport reads `SMTP_PASSWORD` as its API key whatever +`AUTH_TYPE` is set to, so a connection URI pointing at `smtp.sendgrid.net` needs +your real API key there and a placeholder fails every send with a 401. ### DATABASE_REGION now decides where the function runs From dcb0f00467011cf0cbbd8b2d5a5c2466eba7ef51 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 7 Sep 2026 12:53:16 +0100 Subject: [PATCH 5/6] test(firestore-send-email): clear the SendGrid mocks between transportLayer tests toHaveBeenCalledWith matches any recorded call, so without the reset the setApiKey assertion could be satisfied by an earlier test's call. --- kits/firestore-send-email/tests/helpers.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/kits/firestore-send-email/tests/helpers.test.ts b/kits/firestore-send-email/tests/helpers.test.ts index 69c83bca1e..035538d342 100644 --- a/kits/firestore-send-email/tests/helpers.test.ts +++ b/kits/firestore-send-email/tests/helpers.test.ts @@ -264,6 +264,10 @@ describe("isSendGrid", () => { }); describe("transportLayer", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + test("passes the SMTP password to SendGrid as the API key when AUTH_TYPE is OAuth2", async () => { const transport = await transportLayer( makeConfig({ From 7e8d39fc6aaa6da3e951cd79d2f4c384bd78d6df Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 7 Sep 2026 13:41:32 +0100 Subject: [PATCH 6/6] test(firestore-send-email): stop ambient shell vars reaching the param mock AUTH_TYPE, USER and HOST are all param names that exist as ambient shell vars, and AUTH_TYPE is a real key in the kit's own .env, so sourcing it failed the suite. The mock now reads a map the tests control. --- kits/firestore-send-email/tests/config.test.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/kits/firestore-send-email/tests/config.test.ts b/kits/firestore-send-email/tests/config.test.ts index 5de4aba786..aa2f4221bc 100644 --- a/kits/firestore-send-email/tests/config.test.ts +++ b/kits/firestore-send-email/tests/config.test.ts @@ -21,20 +21,19 @@ interface StringParamOpts { input?: { text?: { validationRegex?: RegExp } }; } -const { stringParamOpts } = vi.hoisted(() => ({ +const { stringParamOpts, paramEnv } = vi.hoisted(() => ({ stringParamOpts: new Map(), + paramEnv: new Map(), })); vi.mock("firebase-functions/params", () => ({ - // Only stubbed names may read the env: ambient shell vars (USER, HOST) - // collide with real param names and would make results machine-dependent. + // Values come from paramEnv rather than process.env: AUTH_TYPE, USER and + // HOST are all real param names that collide with ambient shell vars, which + // would otherwise make results machine-dependent. defineString: (name: string, opts?: StringParamOpts) => { stringParamOpts.set(name, opts); return { - value: () => - (name === "AUTH_TYPE" ? process.env[name] : undefined) ?? - opts?.default ?? - "", + value: () => paramEnv.get(name) ?? opts?.default ?? "", }; }, defineInt: (_name: string, opts?: { default?: number }) => ({ @@ -66,7 +65,7 @@ import { AuthenticatonType } from "../src/types"; describe("configFromEnv", () => { afterEach(() => { - vi.unstubAllEnvs(); + paramEnv.clear(); }); test("maps params and keeps secret-backed values deferred", () => { @@ -79,7 +78,7 @@ describe("configFromEnv", () => { }); test("keeps the SMTP password for OAuth2 so SendGrid can use it as API key", () => { - vi.stubEnv("AUTH_TYPE", AuthenticatonType.OAuth2); + paramEnv.set("AUTH_TYPE", AuthenticatonType.OAuth2); const config = configFromEnv(); expect(config.authType).toBe(AuthenticatonType.OAuth2); expect(typeof config.smtpPassword).toBe("object");