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/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"scripts": {
"build": "tsc -b",
"clean": "tsc -b --clean",
"//pretest": "Only this kit has a test that requires lib/. The release workflow's test job runs npm test without building, so the hook has to live here rather than in CI.",
"pretest": "npm run build",
"test": "vitest run",
"deploy": "pnpm build && firebase deploy --only functions",
"serve": "firebase emulators:start --only functions"
Expand Down
12 changes: 8 additions & 4 deletions 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 sgMail from "@sendgrid/mail";
import { MailService } from "@sendgrid/mail";
import type {
Address,
MailSource,
Expand All @@ -29,9 +29,13 @@ export class SendGridTransport {
public readonly name = "firebase-extensions-nodemailer-sendgrid";
public readonly version = "0.0.1";

// @sendgrid/mail's default export is a shared MailService singleton, so
// setApiKey on it would reconfigure every other transport in the process.
private readonly client = new MailService();

constructor(options: SendGridTransportOptions = {}) {
if (options.apiKey) {
sgMail.setApiKey(options.apiKey);
this.client.setApiKey(options.apiKey);
}
}

Expand Down Expand Up @@ -164,8 +168,8 @@ export class SendGridTransport {
}
}

sgMail
.send(msg as Parameters<typeof sgMail.send>[0])
this.client
.send(msg as Parameters<MailService["send"]>[0])
.then(([response]) => {
const rawQueueId = (response.headers["x-message-id"] ||
response.headers["X-Message-Id"]) as string | undefined;
Expand Down
9 changes: 5 additions & 4 deletions kits/firestore-send-email/tests/build-interop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
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`.
// The subject is the compiled output, not src: tsc's esModuleInterop helper
// strips the prototype methods off the instance @sendgrid/mail exports, and
// vitest's own transform does not reproduce that. The named import in
// nodemailer-sendgrid emits no helper; this guards a revert to a default one.
// The pretest hook builds lib/ before this runs.
const requireBuilt = createRequire(import.meta.url);

describe("built SendGridTransport", () => {
Expand Down
11 changes: 8 additions & 3 deletions kits/firestore-send-email/tests/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ 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.
// @sendgrid/mail exports a MailService singleton whose methods are also the
// module's own exports, plus the MailService class. Constructing the class
// hands back the same spies as the singleton, so the module-level ones see
// whatever the transport's own client was configured with.
vi.mock("@sendgrid/mail", () => {
const mail = { setApiKey: vi.fn(), send: vi.fn() };
return { ...mail, default: mail };
const MailService = vi.fn(function () {
return mail;
});
return { ...mail, MailService };
});

import * as sgMail from "@sendgrid/mail";
Expand Down
36 changes: 33 additions & 3 deletions kits/firestore-send-email/tests/nodemailer-sendgrid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@

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.
// @sendgrid/mail exports a MailService singleton whose methods are also the
// module's own exports, plus the MailService class. Constructing the class
// hands back the same spies as the singleton, so most tests can assert against
// the module-level ones; the isolation tests override the implementation to
// tell the two apart.
vi.mock("@sendgrid/mail", () => {
const mail = {
setApiKey: vi.fn(),
Expand All @@ -29,7 +32,10 @@ vi.mock("@sendgrid/mail", () => {
{},
]),
};
return { ...mail, default: mail };
const MailService = vi.fn(function () {
return mail;
});
return { ...mail, MailService };
});

import * as sgMail from "@sendgrid/mail";
Expand All @@ -43,6 +49,7 @@ import type {

const setApiKey = vi.mocked(sgMail.setApiKey);
const send = vi.mocked(sgMail.send);
const MailServiceMock = vi.mocked(sgMail.MailService);

/** Wraps a normalized mail source in the minimal MailSource shape the transport consumes. */
function mailFrom(source: Partial<MailSource>): MailSource {
Expand Down Expand Up @@ -84,6 +91,29 @@ describe("SendGridTransport", () => {
expect(setApiKey).not.toHaveBeenCalled();
});

test("builds its own MailService rather than using the module singleton", () => {
new SendGridTransport({ apiKey: "API-KEY-123" });
expect(MailServiceMock).toHaveBeenCalledTimes(1);
});

test("keeps API keys separate across transports", () => {
const first = { setApiKey: vi.fn(), send: vi.fn() };
const second = { setApiKey: vi.fn(), send: vi.fn() };
MailServiceMock.mockImplementationOnce(function () {
return first as never;
}).mockImplementationOnce(function () {
return second as never;
});

new SendGridTransport({ apiKey: "FIRST" });
new SendGridTransport({ apiKey: "SECOND" });

expect(first.setApiKey.mock.calls).toEqual([["FIRST"]]);
expect(second.setApiKey.mock.calls).toEqual([["SECOND"]]);
// The shared singleton the module exports is never reconfigured.
expect(setApiKey).not.toHaveBeenCalled();
});

test("calls back with the normalize error and never sends", async () => {
const transport = new SendGridTransport({ apiKey: "X" });
const normalizeError = new Error("normalize failed");
Expand Down
Loading