diff --git a/kits/firestore-send-email/CHANGELOG.md b/kits/firestore-send-email/CHANGELOG.md index 711eb60d36..156348bc17 100644 --- a/kits/firestore-send-email/CHANGELOG.md +++ b/kits/firestore-send-email/CHANGELOG.md @@ -1 +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/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 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/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/config.test.ts b/kits/firestore-send-email/tests/config.test.ts index 95e28f18be..aa2f4221bc 100644 --- a/kits/firestore-send-email/tests/config.test.ts +++ b/kits/firestore-send-email/tests/config.test.ts @@ -14,22 +14,26 @@ * limitations under the License. */ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; interface StringParamOpts { default?: string; input?: { text?: { validationRegex?: RegExp } }; } -const { stringParamOpts } = vi.hoisted(() => ({ +const { stringParamOpts, paramEnv } = vi.hoisted(() => ({ stringParamOpts: new Map(), + paramEnv: new Map(), })); vi.mock("firebase-functions/params", () => ({ + // 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: () => opts?.default ?? "", + value: () => paramEnv.get(name) ?? opts?.default ?? "", }; }, defineInt: (_name: string, opts?: { default?: number }) => ({ @@ -60,6 +64,10 @@ import { resolveConfig } from "../src/export-config"; import { AuthenticatonType } from "../src/types"; describe("configFromEnv", () => { + afterEach(() => { + paramEnv.clear(); + }); + test("maps params and keeps secret-backed values deferred", () => { const config = configFromEnv(); expect(config.mailCollection).toBe("mail"); @@ -68,6 +76,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", () => { + paramEnv.set("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 +97,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..035538d342 100644 --- a/kits/firestore-send-email/tests/helpers.test.ts +++ b/kits/firestore-send-email/tests/helpers.test.ts @@ -17,8 +17,17 @@ import { logger } from "firebase-functions"; import Mail from "nodemailer/lib/mailer"; import { beforeEach, describe, expect, test, vi } from "vitest"; + +// @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"; -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 +262,22 @@ describe("isSendGrid", () => { expect(isSendGrid(makeConfig({}))).toBe(false); }); }); + +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({ + 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(); + }); +}); 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";