diff --git a/kits/firestore-send-email/CHANGELOG.md b/kits/firestore-send-email/CHANGELOG.md index 156348bc1..4a09c1c6b 100644 --- a/kits/firestore-send-email/CHANGELOG.md +++ b/kits/firestore-send-email/CHANGELOG.md @@ -1,3 +1,4 @@ +- fix: map `DATABASE_REGION` to a valid Cloud Run region before using it as the function region. Firestore multi-region locations (`nam5`, `nam7`) now deploy the function to `us-central1` and `eur3` to `europe-west1` instead of failing the deploy; regional locations pass through unchanged. The value is matched case-insensitively. With the parameter unset or empty the function declares no region and the Firebase CLI resolves one at deploy time. - 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 fa8941105..c473548f8 100644 --- a/kits/firestore-send-email/README.md +++ b/kits/firestore-send-email/README.md @@ -22,11 +22,11 @@ Firebase CLI 15.23.0 or later creates that account, grants the roles below, and attaches it to every function in this kit. Do not set a custom runtime service account for this codebase — it conflicts with that automatic setup. -| Role | Why | -|---|---| -| `roles/datastore.user` | read mail documents and write delivery status | -| `roles/eventarc.eventReceiver` | receive Gen2 Firestore trigger events | -| `roles/run.invoker` | allow Eventarc to invoke the Gen2 Cloud Run service | +| Role | Why | +| ------------------------------ | --------------------------------------------------- | +| `roles/datastore.user` | read mail documents and write delivery status | +| `roles/eventarc.eventReceiver` | receive Gen2 Firestore trigger events | +| `roles/run.invoker` | allow Eventarc to invoke the Gen2 Cloud Run service | ## Usage @@ -80,28 +80,28 @@ loads them at deploy time and prompts for any required values that are missing. Rows marked `secret` live in Secret Manager. You can reuse existing secrets; the CLI connects them to the function at deploy time. -| Field | Env var | Required | Default | Description | -|---|---|---|---|---| -| `mailCollection` | `MAIL_COLLECTION` | no | `mail` | Firestore collection of outbound mail docs | -| `defaultFrom` | `DEFAULT_FROM` | yes | — | Default From address | -| `defaultReplyTo` | `DEFAULT_REPLY_TO` | no | (empty) | Default Reply-To address | -| `databaseRegion` | `DATABASE_REGION` | yes | — | Region for the trigger | -| `databaseId` | `DATABASE` | no | `(default)` | Firestore database id | -| `authType` | `AUTH_TYPE` | no | `UsernamePassword` | `UsernamePassword` or `OAuth2` | -| `smtpConnectionUri` | `SMTP_CONNECTION_URI` | no | (empty) | SMTP connection URI (username/password auth) | -| `smtpPassword` | `SMTP_PASSWORD` | secret | — | SMTP password | -| `host` | `HOST` | no | (empty) | SMTP host (OAuth2) | -| `oauthPort` | `OAUTH_PORT` | no | `465` | SMTP port (OAuth2) | -| `oauthSecure` | `OAUTH_SECURE` | no | `true` | Use TLS (OAuth2) | -| `user` | `USER` | no | (empty) | SMTP username (OAuth2) | -| `clientId` | `CLIENT_ID` | secret | — | OAuth2 client id | -| `clientSecret` | `CLIENT_SECRET` | secret | — | OAuth2 client secret | -| `refreshToken` | `REFRESH_TOKEN` | secret | — | OAuth2 refresh token | -| `templatesCollection` | `TEMPLATES_COLLECTION` | no | (empty) | Optional Handlebars templates collection | -| `usersCollection` | `USERS_COLLECTION` | no | (empty) | Optional users collection for recipient lookup | -| `ttlExpireType` | `TTL_EXPIRE_TYPE` | no | `never` | TTL policy for processed docs | -| `ttlExpireValue` | `TTL_EXPIRE_VALUE` | no | `1` | TTL amount when expire type is set | -| `tlsOptions` | `TLS_OPTIONS` | no | `{}` | JSON TLS options for the SMTP transport | +| Field | Env var | Required | Default | Description | +| --------------------- | ---------------------- | -------- | ------------------ | ----------------------------------------------------- | +| `mailCollection` | `MAIL_COLLECTION` | no | `mail` | Firestore collection of outbound mail docs | +| `defaultFrom` | `DEFAULT_FROM` | yes | — | Default From address | +| `defaultReplyTo` | `DEFAULT_REPLY_TO` | no | (empty) | Default Reply-To address | +| `databaseRegion` | `DATABASE_REGION` | yes | (prompted) | Firestore database location; also places the function | +| `databaseId` | `DATABASE` | no | `(default)` | Firestore database id | +| `authType` | `AUTH_TYPE` | no | `UsernamePassword` | `UsernamePassword` or `OAuth2` | +| `smtpConnectionUri` | `SMTP_CONNECTION_URI` | no | (empty) | SMTP connection URI (username/password auth) | +| `smtpPassword` | `SMTP_PASSWORD` | secret | — | SMTP password | +| `host` | `HOST` | no | (empty) | SMTP host (OAuth2) | +| `oauthPort` | `OAUTH_PORT` | no | `465` | SMTP port (OAuth2) | +| `oauthSecure` | `OAUTH_SECURE` | no | `true` | Use TLS (OAuth2) | +| `user` | `USER` | no | (empty) | SMTP username (OAuth2) | +| `clientId` | `CLIENT_ID` | secret | — | OAuth2 client id | +| `clientSecret` | `CLIENT_SECRET` | secret | — | OAuth2 client secret | +| `refreshToken` | `REFRESH_TOKEN` | secret | — | OAuth2 refresh token | +| `templatesCollection` | `TEMPLATES_COLLECTION` | no | (empty) | Optional Handlebars templates collection | +| `usersCollection` | `USERS_COLLECTION` | no | (empty) | Optional users collection for recipient lookup | +| `ttlExpireType` | `TTL_EXPIRE_TYPE` | no | `never` | TTL policy for processed docs | +| `ttlExpireValue` | `TTL_EXPIRE_VALUE` | no | `1` | TTL amount when expire type is set | +| `tlsOptions` | `TLS_OPTIONS` | no | `{}` | JSON TLS options for the SMTP transport | ## Multiple instances @@ -161,15 +161,41 @@ your real API key there and a placeholder fails every send with a 401. ### DATABASE_REGION now decides where the function runs -In the extension it only told the trigger where your database lived; the function -itself ran in the Cloud Functions location you picked at install. The kit passes -`DATABASE_REGION` straight through as the function's region, so the function -moves to your database's region and the install-time location setting has no -replacement. If your Firestore is multi-region or dual-region (`nam5`, `nam7`, -`eur3`), that value is not a Cloud Functions region and the deploy fails; deploy -the trigger yourself from the package's `./lib` entry point with a real region -such as `us-central1` or `europe-west1`. This was not exercised against a live -deploy. +In the extension it only told the trigger where your database lived; the +function itself always ran in `us-central1`, as the extension offered no +location setting. The kit deploys the function to the region derived from +`DATABASE_REGION`, so the function moves next to your database. Regional +Firestore locations (`europe-west2`, `us-east1`, ...) are used as-is; the +multi-region locations map to a Cloud Run region inside them - `nam5` and +`nam7` to `us-central1`, `eur3` to `europe-west1` - because they are not Cloud +Run regions themselves and would fail the deploy. The value is matched +case-insensitively. The Firestore trigger always fires in the database's own +region, whatever region the function runs in. + +If you copied `DATABASE_REGION` into your `.env` from an extension install, it +is honored: the function deploys near your database. + +Placement needs firebase-tools 15.28.0 or later - older CLIs do not load +`.env` values during deploy discovery, so the function silently falls back to +the no-region behavior below. Two consequences worth knowing before you +deploy. Upgrading the CLI (or this kit, if your `.env` already carried +`DATABASE_REGION`) can itself trigger the region move described below on your +next deploy. And on a fresh interactive install the value you enter at the +prompt only takes effect from the second deploy: the first deploy computes the +region before the prompt runs, so it lands in `us-central1` and the next deploy +moves the function. + +With `DATABASE_REGION` unset or empty, the function declares no region and the +Firebase CLI resolves one at deploy time: it keeps the region it is already +deployed in, and on a first deploy lands in `us-central1` unless you set the +`FIREBASE_FUNCTIONS_DEFAULT_REGION` environment variable when running +`firebase deploy`. Careful with that variable: it applies to every no-region +function in the deploy, not just this kit. Note that changing an existing +install's function region deletes and recreates the function in the new +region. `processQueue` is the kit's only function and nothing reconciles the +mail collection afterwards, so any document written while the function is gone +is never delivered. Stop writers and let the collection drain before a deploy +that moves the region. ### Create the Eventarc channel yourself for events diff --git a/kits/firestore-send-email/src/config.ts b/kits/firestore-send-email/src/config.ts index 9c6fc8c93..883d16752 100644 --- a/kits/firestore-send-email/src/config.ts +++ b/kits/firestore-send-email/src/config.ts @@ -27,6 +27,7 @@ import type { SecretValue, SendEmailConfig, } from "./export-config"; +import { firestoreLocationToFunctionRegion } from "./region"; import { AuthenticatonType } from "./types"; const DATABASE_REGION_OPTIONS = [ @@ -96,7 +97,7 @@ const params = { databaseRegion: defineString("DATABASE_REGION", { label: "Firestore Instance Location", description: - "Where is the Firestore database located? You can check your current database location at [https://console.cloud.google.com/firestore/databases](https://console.cloud.google.com/firestore/databases).", + "Where is the Firestore database located? You can check your current database location at [https://console.cloud.google.com/firestore/databases](https://console.cloud.google.com/firestore/databases). The function in this kit deploys to the Cloud Run region closest to this location.", input: select({ "Multi-region (Europe - Belgium and Netherlands)": "eur3", @@ -378,9 +379,14 @@ export function configFromEnv(): SendEmailConfig { } export function envDeployOptions(): DeployTimeOptions { + // The multi-region to Cloud Run region lookup cannot be expressed in CEL, so + // the value is read from `process.env` (populated from `.env` during CLI + // discovery) instead of via the param expression. + const region = firestoreLocationToFunctionRegion(process.env.DATABASE_REGION); + return { document: expr`${params.mailCollection}/{documentId}`, database: params.databaseId, - region: params.databaseRegion, + ...(region ? { region } : {}), }; } diff --git a/kits/firestore-send-email/src/export-config.ts b/kits/firestore-send-email/src/export-config.ts index b1a6346e6..90ecf22b6 100644 --- a/kits/firestore-send-email/src/export-config.ts +++ b/kits/firestore-send-email/src/export-config.ts @@ -57,7 +57,8 @@ export interface SendEmailConfig { export interface DeployTimeOptions { document: string | Expression; database: string | Expression; - region: string | Expression; + /** Cloud Run region for the function; omitted when `DATABASE_REGION` is unset. */ + region?: string; } export interface ResolvedSendEmailConfig { diff --git a/kits/firestore-send-email/src/index.ts b/kits/firestore-send-email/src/index.ts index 7879d356b..094f7cf62 100644 --- a/kits/firestore-send-email/src/index.ts +++ b/kits/firestore-send-email/src/index.ts @@ -92,7 +92,7 @@ function ensureInitialized(): Promise { export const processQueue = onDocumentWritten( { - region: deploy.region, + ...(deploy.region ? { region: deploy.region } : {}), document: deploy.document, database: deploy.database, timeoutSeconds: 120, diff --git a/kits/firestore-send-email/src/region.ts b/kits/firestore-send-email/src/region.ts new file mode 100644 index 000000000..0ca47d4b2 --- /dev/null +++ b/kits/firestore-send-email/src/region.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2019 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. + */ + +// Firestore multi-region locations are not Cloud Run regions; deploying a +// function to one hard-fails, so they map to a region inside the multi-region. +const MULTI_REGION_TO_FUNCTION_REGION: Record = { + nam5: "us-central1", + nam7: "us-central1", + eur3: "europe-west1", +}; + +/** + * Maps a Firestore database location to the Cloud Run region the functions + * should deploy to. The lookup is case-insensitive and ignores surrounding + * whitespace, as the CLI's own region handling is. Regional locations pass + * through lowercased; an unset or blank location returns `undefined`, meaning + * the functions declare no region. + */ +export function firestoreLocationToFunctionRegion( + location: string | undefined +): string | undefined { + const normalized = location?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + return MULTI_REGION_TO_FUNCTION_REGION[normalized] ?? normalized; +} diff --git a/kits/firestore-send-email/tests/deploy-options.test.ts b/kits/firestore-send-email/tests/deploy-options.test.ts index e44cecec4..52f2e8748 100644 --- a/kits/firestore-send-email/tests/deploy-options.test.ts +++ b/kits/firestore-send-email/tests/deploy-options.test.ts @@ -15,32 +15,75 @@ */ import { Expression } from "firebase-functions/params"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; import { envDeployOptions } from "../src/config"; const cel = (value: unknown): string => value instanceof Expression ? value.toCEL() : String(value); +const originalDatabaseRegion = process.env.DATABASE_REGION; + +function setDatabaseRegion(value?: string): void { + if (value === undefined) { + delete process.env.DATABASE_REGION; + } else { + process.env.DATABASE_REGION = value; + } +} + +afterEach(() => { + setDatabaseRegion(originalDatabaseRegion); +}); + describe("envDeployOptions", () => { - const options = envDeployOptions(); + test("emits CEL for document and database", () => { + const options = envDeployOptions(); - test("emits CEL for document, database, and region", () => { expect(options.document).toBeInstanceOf(Expression); expect(options.database).toBeInstanceOf(Expression); - expect(options.region).toBeInstanceOf(Expression); expect(cel(options.document)).toBe( "{{ params.MAIL_COLLECTION }}/{documentId}" ); expect(cel(options.database)).toBe("{{ params.DATABASE }}"); - expect(cel(options.region)).toBe("{{ params.DATABASE_REGION }}"); + }); + + test.each([ + ["nam5", "us-central1"], + ["nam7", "us-central1"], + ["eur3", "europe-west1"], + ])( + "multi-region DATABASE_REGION %s maps the function region to %s", + (databaseRegion, expectedRegion) => { + setDatabaseRegion(databaseRegion); + expect(envDeployOptions().region).toBe(expectedRegion); + } + ); + + test("regional DATABASE_REGION passes through as the function region", () => { + setDatabaseRegion("europe-west1"); + expect(envDeployOptions().region).toBe("europe-west1"); + }); + + test("unset DATABASE_REGION omits the region option", () => { + setDatabaseRegion(undefined); + expect(envDeployOptions()).not.toHaveProperty("region"); + }); + + test("empty DATABASE_REGION omits the region option", () => { + setDatabaseRegion(""); + expect(envDeployOptions()).not.toHaveProperty("region"); }); test("serialized deploy-time options do not contain undefined", () => { + setDatabaseRegion(undefined); const serialized = JSON.stringify( Object.fromEntries( - Object.entries(options).map(([key, value]) => [key, cel(value)]) + Object.entries(envDeployOptions()).map(([key, value]) => [ + key, + cel(value), + ]) ) ); diff --git a/kits/firestore-send-email/tests/index.test.ts b/kits/firestore-send-email/tests/index.test.ts new file mode 100644 index 000000000..ee37425b5 --- /dev/null +++ b/kits/firestore-send-email/tests/index.test.ts @@ -0,0 +1,92 @@ +/** + * 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 { afterEach, describe, expect, test, vi } from "vitest"; + +vi.mock("firebase-functions/firestore", () => ({ + onDocumentWritten: vi.fn(() => ({})), +})); +vi.mock("firebase-functions/v2", () => ({ + requiresAPI: vi.fn(), + requiresRole: vi.fn(), +})); + +type FunctionOptions = Record; + +const originalDatabaseRegion = process.env.DATABASE_REGION; + +afterEach(() => { + if (originalDatabaseRegion === undefined) { + delete process.env.DATABASE_REGION; + } else { + process.env.DATABASE_REGION = originalDatabaseRegion; + } +}); + +async function loadTriggerOptions( + databaseRegion?: string +): Promise { + vi.resetModules(); + if (databaseRegion === undefined) { + delete process.env.DATABASE_REGION; + } else { + process.env.DATABASE_REGION = databaseRegion; + } + + await import("../src/index"); + const { onDocumentWritten } = await import("firebase-functions/firestore"); + + const calls = vi.mocked(onDocumentWritten).mock.calls; + expect(calls.length).toBeGreaterThan(0); + return calls[calls.length - 1][0] as FunctionOptions; +} + +describe("processQueue options", () => { + test.each([ + ["nam5", "us-central1"], + ["nam7", "us-central1"], + ["eur3", "europe-west1"], + ["NAM5", "us-central1"], + ])( + "multi-region DATABASE_REGION %s deploys the function to %s", + async (databaseRegion, expectedRegion) => { + const options = await loadTriggerOptions(databaseRegion); + expect(options.region).toBe(expectedRegion); + } + ); + + test("regional DATABASE_REGION passes through as the function region", async () => { + const options = await loadTriggerOptions("europe-west1"); + expect(options.region).toBe("europe-west1"); + }); + + test("unset DATABASE_REGION leaves the function without a region", async () => { + const options = await loadTriggerOptions(); + expect(options).not.toHaveProperty("region"); + }); + + test("empty DATABASE_REGION leaves the function without a region", async () => { + const options = await loadTriggerOptions(""); + expect(options).not.toHaveProperty("region"); + }); + + test("the trigger binds to the configured database and collection", async () => { + const options = await loadTriggerOptions(); + expect(String(options.database)).toBe("params.DATABASE"); + const document = options.document as { toCEL(): string }; + expect(document.toCEL()).toContain("params.MAIL_COLLECTION"); + }); +}); diff --git a/kits/firestore-send-email/tests/region.test.ts b/kits/firestore-send-email/tests/region.test.ts new file mode 100644 index 000000000..5d983bf0c --- /dev/null +++ b/kits/firestore-send-email/tests/region.test.ts @@ -0,0 +1,62 @@ +/** + * 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 { describe, expect, test } from "vitest"; + +import { firestoreLocationToFunctionRegion } from "../src/region"; + +describe("firestoreLocationToFunctionRegion", () => { + test.each([ + ["nam5", "us-central1"], + ["nam7", "us-central1"], + ["eur3", "europe-west1"], + ])("maps the multi-region location %s to %s", (location, region) => { + expect(firestoreLocationToFunctionRegion(location)).toBe(region); + }); + + test.each(["us-central1", "europe-west1", "asia-northeast1", "us-east1"])( + "passes the regional location %s through unchanged", + (location) => { + expect(firestoreLocationToFunctionRegion(location)).toBe(location); + } + ); + + test("returns undefined for an unset location", () => { + expect(firestoreLocationToFunctionRegion(undefined)).toBeUndefined(); + }); + + test("returns undefined for an empty location", () => { + expect(firestoreLocationToFunctionRegion("")).toBeUndefined(); + }); + + test("returns undefined for a whitespace-only location", () => { + expect(firestoreLocationToFunctionRegion(" ")).toBeUndefined(); + }); + + test.each([ + ["NAM5", "us-central1"], + ["Eur3", "europe-west1"], + [" nam7 ", "us-central1"], + ])("normalizes %s before the multi-region lookup", (location, region) => { + expect(firestoreLocationToFunctionRegion(location)).toBe(region); + }); + + test("lowercases and trims a regional location", () => { + expect(firestoreLocationToFunctionRegion(" Europe-West2 ")).toBe( + "europe-west2" + ); + }); +});