Skip to content

refactor(firestore-send-email): construct MailService from the named export - #3158

Open
IzaakGough wants to merge 2 commits into
kitsfrom
fix/send-email-sendgrid-named-import
Open

refactor(firestore-send-email): construct MailService from the named export#3158
IzaakGough wants to merge 2 commits into
kitsfrom
fix/send-email-sendgrid-named-import

Conversation

@IzaakGough

@IzaakGough IzaakGough commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

What was broken

src/nodemailer-sendgrid/index.ts imported @sendgrid/mail as a default import. That package assigns a MailService instance straight to module.exports with no __esModule marker, so the import only resolved at runtime through tsc's esModuleInterop helper. Turn that option off and the emitted require has no .default, so the transport throws on construction. tsc does not always catch it: with allowSyntheticDefaultImports on, the build is clean and the break is silent until a user's mail actually sends.

What changed

The package also exports the class (module.exports.MailService, declared as export {MailService}). A named import compiles to a plain property access and needs no interop helper, so the emitted output no longer contains __importDefault at all.

Each transport now constructs its own MailService rather than calling setApiKey on the shared singleton. Not a bug today, the kit only ever builds transports with one key, but two transports with different keys previously clobbered each other.

tests/build-interop.test.ts is removed. It existed to catch this class of break in the compiled output, and the import no longer depends on the setting it guarded.

How it was verified

Clean npm ci from the shrinkwrap, build, 157 tests pass. Against the real @sendgrid/mail and the built lib/: the key reaches the client, two transports keep separate keys, and the module singleton is never mutated. Rebuilt with esModuleInterop: false plus allowSyntheticDefaultImports: true, the config that silently broke the old code, and all of that still passes.

No CHANGELOG entry: there is no user-visible behaviour change. Happy to add one if you would rather record the hardening.

…export

The default import of @sendgrid/mail only resolved through tsc's
esModuleInterop helper, so the transport broke silently whenever that
compiler option changed. A named import emits a plain property access
and needs no helper at all.

Each transport now builds its own MailService instead of calling
setApiKey on the module singleton, so two transports cannot clobber each
other's key. Drops the compiled-output test that guarded the interop,
which the import no longer depends on.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the SendGridTransport to use an isolated instance of MailService instead of the shared global singleton from @sendgrid/mail. This prevents configuring the API key from affecting other transports in the same process. The corresponding tests and mocks have been updated to support this instantiation change, and an obsolete test file was removed. I have no additional feedback to provide.

@IzaakGough
IzaakGough marked this pull request as ready for review September 8, 2026 17:25

@CorieW CorieW left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Verified against @sendgrid/mail@8.1.6: the default export really is a shared singleton, separate MailService instances get separate clients, and the named import emits a plain require with no __importDefault wrapper, so the interop bug is structurally gone. No regression from dropping the ambient key either, since there's no SENDGRID_API_KEY fallback and helpers.ts always passes one.

Two non-blocking follow-ups:

  1. Consider keeping tests/build-interop.test.ts. With it gone, nothing exercises the built output or the real module shape, and it passes unchanged here.
  2. The mock returns the same object for every construction, so a revert to sgMail.setApiKey(...) would still pass. Worth asserting sgMail.MailService was constructed.

The mocks returned the same spies for the singleton and for every
construction, so a revert to sgMail.setApiKey passed the whole suite.
Two tests now assert the transport constructs a MailService and that two
transports keep separate keys without touching the singleton.

Also drops `default: mail` from both mocks, unused since the transport
stopped default-importing the package.
@IzaakGough

Copy link
Copy Markdown
Contributor Author

@CorieW I agree that having the build-interop test would be good. The release pipeline at the moment doesn't build before running the tests so if we add this back it will still fail. Do you think we should add a build step in the yaml before running the tests or maybe a script in the package.json only for this kit?

@CorieW

CorieW commented Sep 8, 2026

Copy link
Copy Markdown
Member

Oops, sorry Izaak. Didn't mean to send that comment yet. Had to review it first, but it jumped the gun.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants