Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions kits/firestore-send-email/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion kits/firestore-send-email/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 11 additions & 5 deletions kits/firestore-send-email/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
Comment thread
IzaakGough marked this conversation as resolved.
case AuthenticatonType.UsernamePassword:
case AuthenticatonType.ApiKey:
return [params.smtpPassword];
Expand All @@ -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),
Comment thread
IzaakGough marked this conversation as resolved.
defaultFrom: params.defaultFrom.value(),
Comment thread
IzaakGough marked this conversation as resolved.
defaultReplyTo: params.defaultReplyTo.value(),
usersCollection: params.usersCollection.value(),
Expand Down
2 changes: 1 addition & 1 deletion kits/firestore-send-email/src/nodemailer-sendgrid/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import * as sgMail from "@sendgrid/mail";
import sgMail from "@sendgrid/mail";
import type {
Address,
MailSource,
Expand Down
31 changes: 31 additions & 0 deletions kits/firestore-send-email/tests/build-interop.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
28 changes: 23 additions & 5 deletions kits/firestore-send-email/tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, StringParamOpts | undefined>(),
paramEnv: new Map<string, string>(),
}));

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 }) => ({
Expand Down Expand Up @@ -60,6 +64,10 @@ import { resolveConfig } from "../src/export-config";
import { AuthenticatonType } from "../src/types";

describe("configFromEnv", () => {
afterEach(() => {
paramEnv.clear();
});
Comment thread
IzaakGough marked this conversation as resolved.

test("maps params and keeps secret-backed values deferred", () => {
const config = configFromEnv();
expect(config.mailCollection).toBe("mail");
Expand All @@ -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");
});
Comment thread
IzaakGough marked this conversation as resolved.
});

describe("secretParamsForAuthType", () => {
Expand All @@ -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"]);
});
Comment thread
IzaakGough marked this conversation as resolved.

test("uses username/password secret binding by default", () => {
Expand Down
30 changes: 29 additions & 1 deletion kits/firestore-send-email/tests/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -253,3 +262,22 @@ describe("isSendGrid", () => {
expect(isSendGrid(makeConfig({}))).toBe(false);
});
});

describe("transportLayer", () => {
Comment thread
IzaakGough marked this conversation as resolved.
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();
});
});
25 changes: 15 additions & 10 deletions kits/firestore-send-email/tests/nodemailer-sendgrid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading