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
1 change: 1 addition & 0 deletions kits/firestore-send-email/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
98 changes: 62 additions & 36 deletions kits/firestore-send-email/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Comment thread
cabljac marked this conversation as resolved.

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
Comment thread
cabljac marked this conversation as resolved.
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

Expand Down
10 changes: 8 additions & 2 deletions kits/firestore-send-email/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
SecretValue,
SendEmailConfig,
} from "./export-config";
import { firestoreLocationToFunctionRegion } from "./region";
import { AuthenticatonType } from "./types";

const DATABASE_REGION_OPTIONS = [
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 } : {}),
};
}
3 changes: 2 additions & 1 deletion kits/firestore-send-email/src/export-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ export interface SendEmailConfig {
export interface DeployTimeOptions {
document: string | Expression<string>;
database: string | Expression<string>;
region: string | Expression<string>;
/** Cloud Run region for the function; omitted when `DATABASE_REGION` is unset. */
region?: string;
}

export interface ResolvedSendEmailConfig {
Expand Down
2 changes: 1 addition & 1 deletion kits/firestore-send-email/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ function ensureInitialized(): Promise<HandlerContext> {

export const processQueue = onDocumentWritten(
{
region: deploy.region,
...(deploy.region ? { region: deploy.region } : {}),
document: deploy.document,
database: deploy.database,
timeoutSeconds: 120,
Expand Down
40 changes: 40 additions & 0 deletions kits/firestore-send-email/src/region.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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;
}
Comment on lines +32 to +40

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.

medium

To make the region mapping more robust against accidental leading/trailing whitespace or casing differences in the .env file (for example, DATABASE_REGION= nam5 or DATABASE_REGION=NAM5), it is recommended to trim and lowercase the input location before performing the lookup.

Suggested change
export function firestoreLocationToFunctionRegion(
location: string | undefined
): string | undefined {
if (!location) {
return undefined;
}
return MULTI_REGION_TO_FUNCTION_REGION[location] ?? location;
}
export function firestoreLocationToFunctionRegion(
location: string | undefined
): string | undefined {
if (!location) {
return undefined;
}
const normalized = location.trim().toLowerCase();
if (!normalized) {
return undefined;
}
return MULTI_REGION_TO_FUNCTION_REGION[normalized] ?? normalized;
}

55 changes: 49 additions & 6 deletions kits/firestore-send-email/tests/deploy-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Comment thread
cabljac marked this conversation as resolved.
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),
])
)
);

Expand Down
92 changes: 92 additions & 0 deletions kits/firestore-send-email/tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<FunctionOptions> {
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");
});
});
Loading
Loading