Sending Email from Google Cloud

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Commands and behaviour on this page are current for the gcloud CLI 586.x line, the Google Cloud Client Libraries (libraries-bom), Spring Boot 3.5 and 4 with spring-boot-starter-mail, Quarkus with quarkus-mailer, and the SendGrid Java library, all checked against the official documentation on 2026-10-01; re-verify against gcloud version and the linked pages before relying on them in a pipeline. Examples use the placeholder project my-project-id, the region us-central1, the placeholder domain example.com and the sender no-reply@example.com; all of them are stand-ins for your own values, and every credential below is an obvious placeholder such as ${SENDGRID_API_KEY} or REPLACE_ME, read at run time from Secret Manager or the environment.

In the Bookshelf scenario the notifier worker sends an order-confirmation message every time orders-api publishes an order to the Pub/Sub topic orders. On AWS that job belongs to Amazon SES (see Sending Email with Amazon SES) and on Azure to Communication Services (see Email with Azure Communication Services). Google Cloud is different, and this page says so up front, then builds the whole path with one provider: SendGrid, with the API key in Secret Manager and a notifier that cannot be invoked by the public.

The constraints, stated plainly

Two facts shape every design on this page.

  • Google Cloud has no first-party transactional email service. There is no product comparable to SES or Azure Email Communication Services. Sending email means choosing a third-party provider (or a Google Workspace mailbox) and calling it.

  • Outbound TCP port 25 to destinations outside your VPC network is blocked. Port 25 inside the VPC is allowed, but a mail server of your own on Compute Engine cannot deliver straight to the internet. Google places no restriction on outbound TCP 587 and 465 to external addresses, and a provider may additionally offer a non-standard port such as 2525 that you open with an egress firewall rule if your rules deny egress.

The consequences: connect to a provider on 587 (STARTTLS) or 465 (implicit TLS), or use the provider’s HTTPS API, which needs only outbound 443 and is the most robust choice behind a locked-down network. Never rely on port 25 working the way it does on a laptop or a classic hosting provider. Older guides, including recipes that wire a function straight to a mail API, still show copying the provider key into a plain environment variable and deploying the function publicly; the current approach on this page is a Secret Manager secret and an authenticated invoker.

The options

Google’s own guide, "Sending email from an instance", lists the following, in this order.

Option What it is Choose it when

SendGrid (Twilio SendGrid)

Hosted email API and SMTP relay, available through the Google Cloud Marketplace. The free plan was retired on 2025-05-27; accounts created since 2025-03-25 get a 60-day trial.

You want the path Google documents first, strong tooling for templates, signed event webhooks and dedicated IPs.

Mailgun

Hosted email API and SMTP relay. Google’s guide lists it with a free allowance for Compute Engine customers.

You prefer its routing and inbound-parsing features, or its regional (EU) endpoints for data residency.

Mailjet

Hosted email API and SMTP relay, also listed in Google’s guide.

You want a provider with a free tier and a visual template editor.

Google Workspace SMTP relay (smtp-relay.gmail.com)

Relay of mail from your own apps through your Workspace organisation. Ports 465 and 587 only (not 25 from Google Cloud). Authenticated by SMTP credentials or by the sending IP address.

You already pay for Workspace, mail should come from your organisation’s domain, and volumes are modest.

Gmail API

REST API acting on a Workspace-owned mailbox (service account with domain-wide delegation, or OAuth user consent).

The application must send as, or read, a specific mailbox. Not a bulk or transactional engine.

Firebase Trigger Email extension

Sends an SMTP message for each document written to a Firestore collection.

A Firebase/Firestore-centred app wants fire-and-forget email without writing a worker.

The first three behave alike from the application’s point of view, which is why the rest of the page uses SendGrid as the worked example and notes where Mailgun and Mailjet differ.

Aspect SendGrid Mailgun Mailjet Workspace relay

Interface

HTTPS API v3 and SMTP (smtp.sendgrid.net)

HTTPS API and SMTP (smtp.mailgun.org)

HTTPS API v3.1 and SMTP (in-v3.mailjet.com)

SMTP only (smtp-relay.gmail.com)

Ports from Google Cloud

587, 465, 2525; API on 443

587, 465, 2525; API on 443

587, 465, 2525; API on 443

587, 465

Free allowance

60-day trial only since 2025; paid plans after

Small free tier or trial, check the current plan page

Small free tier, check the current plan page

None beyond the Workspace licence

Dedicated IPs

Paid plans

Paid plans

Paid plans

Google’s shared relay addresses

Regions and data residency

Global; an EU regional subuser option exists

US and EU regions chosen at domain setup

EU-based by default with a US option

Follows your Workspace data region

Free allowances, plan names and regions change often; treat the table as a map of what to check, and read each provider’s pricing page on the day you decide.

How the pieces fit

sequenceDiagram participant PS as Pub/Sub topic orders participant N as notifier (Cloud Run, private) participant SM as Secret Manager participant P as Email provider (SendGrid) participant R as Recipient mail server participant W as Webhook service (Cloud Run) participant ST as Pub/Sub topic email-events PS->>N: push with OIDC token (run.invoker) N->>SM: read sendgrid-api-key (once, at start) N->>P: HTTPS API call or SMTP on 587 P-->>N: 202 accepted P->>R: SMTP delivery, DKIM-signed alt Delivered R-->>P: 250 accepted P->>W: webhook event delivered else Bounced R-->>P: 550 mailbox unavailable P->>W: webhook event bounce end W->>ST: publish event

Three separate ideas live in the diagram. The send path (Pub/Sub, notifier, provider) is private to your project and authenticated at every hop. The credential path (Secret Manager to notifier) keeps the provider key out of images and configuration files. The event path (provider webhook, a small public endpoint, Pub/Sub) brings delivery outcomes back so the application can act on bounces and complaints.

Prepare the project

Enable the APIs this page uses.

export PROJECT_ID=$(gcloud config get-value project)
export REGION=us-central1

gcloud services enable \
  run.googleapis.com \
  secretmanager.googleapis.com \
  pubsub.googleapis.com \
  artifactregistry.googleapis.com \
  cloudbuild.googleapis.com \
  dns.googleapis.com

In the console, pick the project in the project picker, open APIs & Services → Library, search for each API and click Enable. Cloud Shell has gcloud already authenticated if you would rather not install it locally.

Set up SendGrid

Create the account

In the Google Cloud console

  1. Open Marketplace, search for SendGrid and open the Twilio SendGrid listing.

  2. Click Subscribe (or the equivalent sign-up button for the plan shown), accept the terms and follow the redirect to SendGrid to finish creating the account. SendGrid then bills you directly, or through your Google Cloud billing account if the listing is a billed Marketplace product.

  3. In the SendGrid console choose Settings → Sender Authentication and continue with the next section.

With the gcloud CLI

Account creation is a Marketplace and provider step, not a Google Cloud API call, so there is no gcloud command for it. You can browse the catalog from the CLI, which is useful for scripting an inventory of subscribed services:

gcloud services list --enabled --filter="name:endpoints OR config.name:sendgrid" \
  --format="table(config.name,config.title)"

Signing up directly on the provider’s site works exactly the same; the Marketplace route only changes billing.

Authenticate the sending domain

A provider will send as example.com only after you prove you control it, and receiving servers will trust the mail only if SPF, DKIM and DMARC line up. The records are DNS records, so if the zone is hosted in Cloud DNS you add them there (see Cloud DNS and Certificate Manager).

In the SendGrid and Google Cloud consoles

  1. In the SendGrid console open Settings → Sender Authentication → Authenticate Your Domain, choose Google Cloud DNS or Other host as the DNS host, enter example.com, and keep Use automated security on.

  2. SendGrid shows three CNAME records (for example a em1234 mail-from host and two s1._domainkey and s2._domainkey DKIM hosts) pointing at SendGrid-owned names. Copy them.

  3. In the Google Cloud console open Network services → Cloud DNS, open the zone for example.com and click Add standard.

  4. For each record choose type CNAME, enter the host name relative to the zone, the target with a trailing dot, and a TTL of 300; click Create.

  5. Back in SendGrid click Verify. Verification usually completes within minutes, but can take hours.

  6. Add a DMARC record the same way: type TXT, name _dmarc, value as in the DMARC section below.

With the gcloud CLI

export ZONE=example-com
export DOMAIN=example.com

# DKIM and mail-from CNAMEs: use exactly the names and targets SendGrid shows (placeholders here)
gcloud dns record-sets create "s1._domainkey.${DOMAIN}." \
  --zone="$ZONE" --type=CNAME --ttl=300 \
  --rrdatas="s1.domainkey.<account-id>.sendgrid.example."

gcloud dns record-sets create "s2._domainkey.${DOMAIN}." \
  --zone="$ZONE" --type=CNAME --ttl=300 \
  --rrdatas="s2.domainkey.<account-id>.sendgrid.example."

gcloud dns record-sets create "em1234.${DOMAIN}." \
  --zone="$ZONE" --type=CNAME --ttl=300 \
  --rrdatas="<account-id>.wl.sendgrid.example."

# DMARC: start in monitoring mode
gcloud dns record-sets create "_dmarc.${DOMAIN}." \
  --zone="$ZONE" --type=TXT --ttl=300 \
  --rrdatas='"v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com"'

Derived from Manage DNS records. The targets above end in sendgrid.example. on purpose; replace them with the exact values the provider’s authentication screen shows for your account.

SPF, DKIM and DMARC in one paragraph each

SPF is a TXT record listing the servers allowed to send for a domain. With a provider that authenticates a domain through a mail-from CNAME (the first record above), the provider manages SPF for the return-path domain, and you usually add nothing at the apex. If you also send from Workspace, the apex SPF record must include both senders and stay under ten DNS lookups. DKIM signs each message with a private key held by the provider; the CNAMEs publish the public keys, which also lets the provider rotate keys without your involvement. DMARC tells receivers what to do when SPF and DKIM do not align with the visible From: domain, and where to send aggregate reports. Start with p=none and read the reports for a couple of weeks, move to p=quarantine, and then to p=reject once every legitimate source (the provider, Workspace, a marketing tool) passes. The rules for alignment matter: the domain in the From: header must match the DKIM signing domain or the SPF return-path domain (relaxed alignment allows a sub-domain), which is exactly why domain authentication is not optional.

Create the API key and store it in Secret Manager

Create the key in the SendGrid console under Settings → API Keys → Create API Key, restrict it to Mail Send only (not full access), and copy the value once. It is shown a single time. Put it straight into Secret Manager so it never lands in a repository, a shell history or an image. This is the secret named sendgrid-api-key in the Bookshelf scenario; the general mechanics, versions and rotation are on Secret Manager and Cloud KMS.

In the Google Cloud console

  1. Open Security → Secret Manager and click Create secret.

  2. Enter the name sendgrid-api-key.

  3. Click Upload and choose a local file that contains only the key (preferred over pasting, so the value does not sit in the clipboard history).

  4. Keep Automatically replicate under Replication policy (or pick a regional secret in us-central1).

  5. Click Create secret, then delete the local file.

With the gcloud CLI

# Read the key from a protected file or a prompt; never put it on the command line
read -rs SENDGRID_API_KEY
printf '%s' "$SENDGRID_API_KEY" | gcloud secrets create sendgrid-api-key \
  --replication-policy=automatic \
  --labels=app=bookshelf \
  --data-file=-
unset SENDGRID_API_KEY

Derived from Create a secret.

Rotating the key later is a new version, not a new secret: gcloud secrets versions add sendgrid-api-key --data-file=-, then redeploy or restart consumers that read it at start-up.

Deploy a private notifier on Cloud Run

The notifier is an internal worker, so its Cloud Run service must refuse unauthenticated requests. Only the Pub/Sub subscription that delivers order events, authenticated with a service account, may call it. A function that sends mail and is open to the world is an open relay: anybody who finds the URL can send arbitrary messages as your domain, burn your quota and wreck your sending reputation. This section is the corrected pattern.

Service accounts and roles

Use two dedicated service accounts and nothing broader.

Service account Purpose Roles

notifier-runtime

Runs the notifier revision (its identity).

roles/secretmanager.secretAccessor on the secret sendgrid-api-key only; roles/logging.logWriter is implicit for Cloud Run logging; roles/pubsub.publisher on email-events if it publishes events itself.

pubsub-pusher

Identity Pub/Sub uses to push to notifier, as an OIDC token.

roles/run.invoker on the notifier service only.

In the Google Cloud console

  1. Open IAM & Admin → Service accounts and click Create service account.

  2. Enter notifier-runtime, click Create and continue, skip project-level roles and click Done.

  3. Repeat for pubsub-pusher.

  4. Open Security → Secret Manager, select sendgrid-api-key, open the Permissions tab, click Grant access, enter notifier-runtime@my-project-id.iam.gserviceaccount.com and choose the role Secret Manager Secret Accessor.

With the gcloud CLI

gcloud iam service-accounts create notifier-runtime \
  --display-name="Bookshelf notifier runtime"
gcloud iam service-accounts create pubsub-pusher \
  --display-name="Pub/Sub push to notifier"

gcloud secrets add-iam-policy-binding sendgrid-api-key \
  --member="serviceAccount:notifier-runtime@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/secretmanager.secretAccessor"

Derived from Create service accounts and Manage access to secrets. Identity concepts (attached service accounts, impersonation, why no downloaded keys) are covered on Identity and Access.

Deploy with the secret mounted as an environment variable

Cloud Run resolves a secret reference when the instance starts and exposes it as an ordinary environment variable, so the application code stays the same as with any other configuration source, while the value lives only in Secret Manager.

In the Google Cloud console

  1. Open Cloud Run, click Deploy container → Service, enter the Artifact Registry image us-central1-docker.pkg.dev/my-project-id/bookshelf/notifier:latest, name the service notifier and choose the region us-central1.

  2. Under Authentication choose Require authentication (do not choose Allow unauthenticated invocations).

  3. Expand Container(s), Volumes, Networking, Security. On the Security tab choose the service account notifier-runtime.

  4. On the Variables & Secrets tab click Reference a secret, choose sendgrid-api-key, set Reference method to Exposed as environment variable, name SENDGRID_API_KEY, version latest.

  5. Add the plain variables MAIL_FROM=no-reply@example.com and MAIL_PROVIDER=sendgrid, then click Create.

With the gcloud CLI

export SERVICE=notifier

gcloud run deploy "$SERVICE" \
  --image="us-central1-docker.pkg.dev/${PROJECT_ID}/bookshelf/notifier:latest" \
  --region="$REGION" \
  --service-account="notifier-runtime@${PROJECT_ID}.iam.gserviceaccount.com" \
  --no-allow-unauthenticated \
  --set-env-vars="MAIL_FROM=no-reply@example.com,MAIL_PROVIDER=sendgrid" \
  --set-secrets="SENDGRID_API_KEY=sendgrid-api-key:latest"

# Only the Pub/Sub push identity may invoke it
gcloud run services add-iam-policy-binding "$SERVICE" \
  --region="$REGION" \
  --member="serviceAccount:pubsub-pusher@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/run.invoker"

Derived from Configure secrets for services and Authenticating service-to-service. Deploying, revisions and rollbacks are on Cloud Run. A value pinned to a version number instead of latest is reproducible, but then rotation means redeploying with the new number.

Subscribe notifier to the orders topic

gcloud pubsub topics create orders   # skip if orders-api already created it

NOTIFIER_URL=$(gcloud run services describe "$SERVICE" --region="$REGION" \
  --format="value(status.url)")

gcloud pubsub subscriptions create notifier-push \
  --topic=orders \
  --push-endpoint="${NOTIFIER_URL}/pubsub" \
  --push-auth-service-account="pubsub-pusher@${PROJECT_ID}.iam.gserviceaccount.com" \
  --push-auth-token-audience="$NOTIFIER_URL" \
  --ack-deadline=60 \
  --min-retry-delay=10s --max-retry-delay=300s

Derived from Push subscriptions. In the console this is Pub/Sub → Subscriptions → Create subscription, choose the topic orders, delivery type Push, the endpoint URL, tick Enable authentication and pick pubsub-pusher and the audience. Pub/Sub’s own service agent needs permission to mint tokens for that account (roles/iam.serviceAccountTokenCreator on pubsub-pusher for the project’s Pub/Sub service agent, if the token creation is not already granted in your organisation). Retries, dead-letter topics and ordering are covered on Messaging and Integration, and an Eventarc trigger is the alternative wiring on Cloud Run Functions and Events.

Sending from Java

There are two sensible ways for the notifier to talk to the provider: its HTTPS API, or SMTP on 587 with STARTTLS. The API reports a rich error and returns a message identifier; SMTP is a fit when the application already uses the standard mail abstractions of its framework. Either way the credential is the environment variable that Cloud Run filled from Secret Manager, so no application code ever sees Secret Manager unless you choose to read it directly.

The provider API with the SendGrid Java library

<dependency>
  <groupId>com.sendgrid</groupId>
  <artifactId>sendgrid-java</artifactId>
  <version>${sendgrid.version}</version>
</dependency>
package com.example.bookshelf.notifier;

import com.sendgrid.Method;
import com.sendgrid.Request;
import com.sendgrid.Response;
import com.sendgrid.SendGrid;
import com.sendgrid.helpers.mail.Mail;
import com.sendgrid.helpers.mail.objects.Content;
import com.sendgrid.helpers.mail.objects.Email;

import java.io.IOException;

public class OrderMailer {

    private final SendGrid client;
    private final Email from;

    public OrderMailer() {
        // Cloud Run injects the Secret Manager value as an environment variable
        var apiKey = System.getenv("SENDGRID_API_KEY");
        if (apiKey == null || apiKey.isBlank()) {
            throw new IllegalStateException("SENDGRID_API_KEY is not set");
        }
        this.client = new SendGrid(apiKey);
        this.from = new Email(System.getenv().getOrDefault("MAIL_FROM", "no-reply@example.com"));
    }

    public String sendConfirmation(String to, String orderId) throws IOException {
        var mail = new Mail(
                from,
                "Your Bookshelf order " + orderId,
                new Email(to),
                new Content("text/plain", "Thank you. Order " + orderId + " is confirmed."));
        mail.setCategories(java.util.List.of("order-confirmation"));

        var request = new Request();
        request.setMethod(Method.POST);
        request.setEndpoint("mail/send");
        request.setBody(mail.build());

        Response response = client.api(request);
        if (response.getStatusCode() / 100 != 2) {
            throw new IOException("Provider rejected the message: HTTP " + response.getStatusCode());
        }
        return response.getHeaders().get("X-Message-Id");
    }
}

Derived from Sending email with SendGrid and the provider’s mail send API reference. Mailgun and Mailjet ship their own Java clients with the same shape: build a message, post it, read an identifier from the response. A 2xx from the provider means accepted for delivery, not delivered; delivery outcomes arrive later, through the webhook described below.

Reading the key from Secret Manager in code

If you cannot use the Cloud Run secret reference (for example on Compute Engine or a long-running process that must pick up a rotated key without a restart), read the secret with the client library and Application Default Credentials. The attached service account is the identity; nothing is downloaded.

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.google.cloud</groupId>
      <artifactId>libraries-bom</artifactId>
      <version>${libraries.bom.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>com.google.cloud</groupId>
    <artifactId>google-cloud-secretmanager</artifactId>
  </dependency>
</dependencies>
package com.example.bookshelf.notifier;

import com.google.cloud.secretmanager.v1.SecretManagerServiceClient;
import com.google.cloud.secretmanager.v1.SecretVersionName;

import java.io.IOException;

public final class ApiKeyLoader {

    private ApiKeyLoader() {
    }

    public static String load(String projectId) throws IOException {
        var name = SecretVersionName.of(projectId, "sendgrid-api-key", "latest");
        try (var client = SecretManagerServiceClient.create()) {
            return client.accessSecretVersion(name).getPayload().getData().toStringUtf8();
        }
    }
}

Derived from Access a secret version. Locally, authenticate with gcloud auth application-default login (optionally with --impersonate-service-account=notifier-runtime@my-project-id.iam.gserviceaccount.com) so the same code runs without a key file. Spring Boot applications can instead use the Spring Cloud GCP Secret Manager starter (8.x for Spring Boot 4, 7.4.x for Spring Boot 3) and a sm://sendgrid-api-key property reference; see Configuration and Profiles for how property sources are ordered and refreshed.

SMTP with Spring JavaMailSender

Spring Boot auto-configures a JavaMailSender from spring.mail.* properties when spring-boot-starter-mail is on the classpath. The password is read from the environment variable that Cloud Run filled from Secret Manager, so the properties file contains only a placeholder reference. For SendGrid the SMTP user name is the literal string apikey and the password is the API key.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-mail</artifactId>
</dependency>
spring:
  mail:
    host: smtp.sendgrid.net
    port: 587
    username: apikey
    password: ${SENDGRID_API_KEY}
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true
          connectiontimeout: 5000
          timeout: 5000
          writetimeout: 5000
bookshelf:
  mail:
    from: ${MAIL_FROM:no-reply@example.com}
package com.example.bookshelf.notifier;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;

@Service
public class SmtpOrderMailer {

    private final JavaMailSender sender;
    private final String from;

    public SmtpOrderMailer(JavaMailSender sender, @Value("${bookshelf.mail.from}") String from) {
        this.sender = sender;
        this.from = from;
    }

    public void sendConfirmation(String to, String orderId) throws MessagingException {
        MimeMessage message = sender.createMimeMessage();
        var helper = new MimeMessageHelper(message, false, "UTF-8");
        helper.setFrom(from);
        helper.setTo(to);
        helper.setSubject("Your Bookshelf order " + orderId);
        helper.setText("Thank you. Order " + orderId + " is confirmed.", false);
        sender.send(message);
    }
}

Derived from Sending email from an instance and the Spring Boot reference for mail support. The Spring side of the wiring (the MailSender bean shape, profiles that swap in a local test SMTP server such as MailHog, and @ConfigurationProperties) is on Configuration and Profiles; it is not repeated here. Using 465 instead of 587 means mail.smtp.ssl.enable: true and no STARTTLS.

SMTP with the Quarkus Mailer

quarkus.mailer.host=smtp.sendgrid.net
quarkus.mailer.port=587
quarkus.mailer.start-tls=REQUIRED
quarkus.mailer.username=apikey
quarkus.mailer.password=${SENDGRID_API_KEY}
quarkus.mailer.from=${MAIL_FROM:no-reply@example.com}
quarkus.mailer.mock=false
%dev.quarkus.mailer.mock=true
package com.example.bookshelf.notifier;

import io.quarkus.mailer.Mail;
import io.quarkus.mailer.Mailer;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

@ApplicationScoped
public class QuarkusOrderMailer {

    @Inject
    Mailer mailer;

    public void sendConfirmation(String to, String orderId) {
        mailer.send(Mail.withText(
                to,
                "Your Bookshelf order " + orderId,
                "Thank you. Order " + orderId + " is confirmed."));
    }
}

Derived from the Quarkus mailer guide and Configure secrets for services. The Mailer’s reactive API, templates and mock mode in tests are on Scheduling and Mail.

Sending from Compute Engine with Postfix

A VM that already runs software expecting a local MTA (a legacy application, cron mail) can keep using Postfix as a relay: the application hands mail to Postfix on localhost, and Postfix forwards it to the provider on a permitted port, never port 25. This is the setup Google’s SendGrid and Mailjet guides walk through.

sudo apt-get update && sudo apt-get install -y postfix libsasl2-modules

# Fetch the key at boot from Secret Manager using the VM's attached service account
SENDGRID_API_KEY=$(gcloud secrets versions access latest --secret=sendgrid-api-key)

echo "[smtp.sendgrid.net]:587 apikey:${SENDGRID_API_KEY}" | sudo tee /etc/postfix/sasl_passwd >/dev/null
sudo chmod 600 /etc/postfix/sasl_passwd
sudo postmap /etc/postfix/sasl_passwd
unset SENDGRID_API_KEY

sudo postconf -e 'relayhost = [smtp.sendgrid.net]:587'
sudo postconf -e 'smtp_sasl_auth_enable = yes'
sudo postconf -e 'smtp_sasl_security_options = noanonymous'
sudo postconf -e 'smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd'
sudo postconf -e 'smtp_tls_security_level = encrypt'
sudo postconf -e 'smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt'
sudo postconf -e 'inet_interfaces = loopback-only'
sudo systemctl restart postfix

echo "relay test" | mail -s "Postfix via provider" you@example.com

Derived from Sending email with SendGrid. Two details matter. inet_interfaces = loopback-only stops Postfix from accepting mail from other hosts, which is the open-relay mistake in its classic form. And the VM’s attached service account needs roles/secretmanager.secretAccessor on the one secret. Google’s guide uses port 2525 for the relay; 587 works the same and needs no egress firewall rule. If your VPC has deny-egress rules, add an allow rule for the chosen port to the provider’s addresses (see VPC Networking on Google Cloud). The VM itself is described on Compute Engine. Prefer the provider’s API or SMTP directly from the application on Cloud Run; Postfix adds an operating-system package to patch and a queue to monitor.

The Google Workspace SMTP relay

If the organisation already has Google Workspace, an administrator can allow apps to relay through smtp-relay.gmail.com. From Google Cloud only ports 465 and 587 work for it, since port 25 is blocked.

In the Google Workspace Admin console

  1. Open the Admin console at admin.google.com and go to Apps → Google Workspace → Gmail → Routing.

  2. Under SMTP relay service click Configure (or Add another rule).

  3. Name the rule bookshelf-notifier, choose Allowed senders: Only registered Apps users in my domains or Only addresses in my domains, and tick Require SMTP Authentication and Require TLS encryption.

  4. Optionally restrict by IP: a Cloud NAT address gives Cloud Run or GKE a fixed egress IP you can allow-list.

  5. Save. Changes can take up to an hour to apply.

From the application

spring:
  mail:
    host: smtp-relay.gmail.com
    port: 587
    username: relay-user@example.com
    password: ${WORKSPACE_RELAY_PASSWORD}
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true

Derived from Send email from a printer, scanner, or app. Store the relay credential in Secret Manager exactly as the SendGrid key above. The relay is meant for an organisation’s own mail, not for bulk marketing: sending limits apply per user and per day, the sender domain must belong to the organisation, and IP-based authentication from Cloud Run requires a static egress address through Cloud NAT and a VPC connector or direct VPC egress.

The Gmail API and the Firebase extension

Gmail API. When the application must send as a particular person or mailbox (a support desk, a shared inbox), use the Gmail API users.messages.send method. For a server-side application the usual pattern is a service account with domain-wide delegation, impersonating one user in the Workspace organisation, with the narrow scope https://www.googleapis.com/auth/gmail.send. Delegation is a powerful grant; scope it to exactly that scope and restrict who may create delegated credentials. The API is subject to per-user rate and daily sending limits, and is a poor fit for transactional volume.

Firebase Trigger Email. The official Firestore "Trigger Email from Firestore" extension watches a collection and sends an SMTP message for every document written to it, recording the delivery state back on the document. Install it from the Firebase console or firebase ext:install firebase/firestore-send-email, give it an SMTP connection URI that references a Secret Manager secret, and the notifier shrinks to writing a document. It suits small Firebase apps; the queue semantics, retries and visibility of a Pub/Sub plus Cloud Run design are better once volume or compliance needs grow.

Delivery events: webhooks into Pub/Sub

A provider tells you what happened to a message by calling a webhook you register. This endpoint is the one part of the design that must be reachable from the internet, so it is a separate Cloud Run service with a single job: verify the request really came from the provider, then publish the event to Pub/Sub and return quickly.

gcloud pubsub topics create email-events

gcloud run deploy email-webhook \
  --image="us-central1-docker.pkg.dev/${PROJECT_ID}/bookshelf/email-webhook:latest" \
  --region="$REGION" \
  --service-account="email-webhook@${PROJECT_ID}.iam.gserviceaccount.com" \
  --allow-unauthenticated \
  --set-env-vars="EVENTS_TOPIC=email-events" \
  --set-secrets="WEBHOOK_PUBLIC_KEY=sendgrid-webhook-key:latest"

Derived from Allowing public (unauthenticated) access. This is the only service on the page deployed with --allow-unauthenticated, and it is safe only because it checks the provider’s signature. SendGrid signs its event webhook with an ECDSA key pair and sends the signature and a timestamp in headers; Mailgun signs with an HMAC of the timestamp and a token; Mailjet supports basic-auth credentials in the webhook URL. Reject anything that fails verification, and put the verification secret in Secret Manager as well.

package com.example.bookshelf.webhook;

import com.google.cloud.pubsub.v1.Publisher;
import com.google.protobuf.ByteString;
import com.google.pubsub.v1.PubsubMessage;
import com.google.pubsub.v1.TopicName;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

public class EventPublisher implements AutoCloseable {

    private final Publisher publisher;

    public EventPublisher(String projectId, String topicId) throws IOException {
        this.publisher = Publisher.newBuilder(TopicName.of(projectId, topicId)).build();
    }

    public void publish(String eventType, String messageId, String json) {
        var message = PubsubMessage.newBuilder()
                .setData(ByteString.copyFromUtf8(json))
                .putAttributes("event", eventType)
                .putAttributes("messageId", messageId)
                .build();
        publisher.publish(message);
    }

    @Override
    public void close() throws Exception {
        publisher.shutdown();
        publisher.awaitTermination(10, TimeUnit.SECONDS);
    }
}

Derived from Publish messages with client libraries. Downstream, a subscription on email-events lets orders-api mark an address as undeliverable after a hard bounce, or write the event to BigQuery for analysis. The event timestamp and attributes on a message are discussed on Messaging and Integration. Providers retry a webhook for a limited time when you answer with an error, so the handler must be idempotent: use the provider’s event identifier to ignore duplicates.

Deliverability

Sending is the easy half; reaching the inbox is the discipline.

  • Authentication aligned. SPF, DKIM and DMARC all pass for the From: domain, as set up above. Check a real message’s headers (Authentication-Results) after the first send.

  • Bulk-sender requirements. Since February 2024 Gmail and Yahoo require senders of large volumes (Google’s threshold is about 5,000 messages a day to Gmail addresses) to authenticate with SPF and DKIM, publish a DMARC policy, keep spam complaints below a low threshold (Google recommends staying under 0.1 percent and never reaching 0.3 percent), and support one-click unsubscribe on marketing mail (the List-Unsubscribe and List-Unsubscribe-Post headers). Enforcement for non-compliant bulk traffic tightened from November 2025, with rejections rather than only spam-folder placement. Transactional mail such as an order confirmation is exempt from the unsubscribe rule but not from authentication.

  • Suppression lists. Providers keep lists of addresses that bounced, complained or unsubscribed and silently drop mail to them. Check them when a customer says "I never got it", and do not retry hard bounces.

  • Dedicated IPs and warm-up. A shared IP pool is fine at low volume. A dedicated IP starts with no reputation, so ramp volume gradually over several weeks (the provider documents a schedule), starting with your most engaged recipients.

  • Separate streams. Use different sub-domains (for example mail.example.com for transactional and news.example.com for marketing) so a complaint about a newsletter does not damage order confirmations.

  • Content. Plain, honest subjects; a text alternative; no URL shorteners; links on your own authenticated domain.

Reference material: Email sender guidelines and the FAQ linked from it.

Quotas and limits

Area Limit to know

Google Cloud network

Outbound TCP 25 to external destinations is blocked; 587 and 465 are open. Port 25 inside the VPC is allowed.

SendGrid

Limits depend on the plan: a daily or monthly message allowance, API rate limits per endpoint, and a message size cap (about 30 MB including attachments). The 60-day trial caps daily volume.

Mailgun

Plan-based monthly allowance, per-domain and per-account rate limits, 25 MB message size by default.

Mailjet

Plan-based daily and monthly allowance, API rate limits, message size cap (about 15 MB).

Workspace SMTP relay

Per-user daily recipient limits that depend on the Workspace edition, a limit on recipients per message, and no support for port 25 from Google Cloud.

Gmail API

Per-user daily sending quota and a per-second rate; large attachments need the upload endpoint.

Pub/Sub push to Cloud Run

Message up to 10 MB, acknowledgement deadline up to 600 seconds, retried with backoff until acknowledged or expired.

Cloud Run

Request timeout up to 60 minutes (default 5 minutes); concurrency per instance configurable up to 1000.

Numbers for third parties change; open the current limits page of the provider you choose (linked from References) and note the date. Cloud quotas are shown in the console under IAM & Admin → Quotas & System Limits.

SMS with Twilio, briefly

Short text messages follow the same architecture as email: a private worker, a provider API, a key in Secret Manager, and a webhook for delivery status. Twilio (the owner of SendGrid) offers an SMS API with a Java library; the account SID and auth token go in Secret Manager exactly like sendgrid-api-key, and an inbound-SMS reply is a webhook to a Cloud Run endpoint that verifies Twilio’s request signature. Google Cloud has no SMS service of its own; Firebase Authentication sends verification SMS messages itself, but only for sign-in. Treat SMS as one more channel behind the same notifier rather than a separate system.

Pricing model and cost drivers

Google Cloud does not charge for email; the provider does, and Google Cloud charges for what runs around it. There are no hard-coded prices here; check the dated pages in References.

  • Provider. Per-message or monthly-plan pricing, with extra cost for dedicated IPs, validation and analytics add-ons. The SendGrid free plan was retired on 2025-05-27, so budget for a paid plan or a trial that ends after 60 days.

  • Cloud Run. Billed for request, CPU and memory time while handling a request; a notifier that mostly waits on the provider’s API is cheap, and scales to zero between orders.

  • Secret Manager. A small monthly charge per active secret version and per access operation, with a free allowance. Reading the secret at start-up rather than per message keeps access counts negligible.

  • Pub/Sub. Charged per volume of data delivered, with a monthly free allowance.

  • Egress. Traffic from Google Cloud to the provider’s API is internet egress and billed by volume, though a message is small. Cloud NAT adds per-gateway and per-GB charges if you need a static egress IP.

  • Compute Engine and Postfix. A VM bills even when idle; one more reason to prefer the serverless path.

Security checklist

  • The provider key is in Secret Manager, restricted to Mail Send, with access granted to one runtime service account.

  • The notifier is deployed with --no-allow-unauthenticated; only the Pub/Sub push identity holds roles/run.invoker.

  • No downloaded service-account keys anywhere: Application Default Credentials with attached accounts, and impersonation on a developer machine.

  • The only public endpoint is the webhook receiver, and it verifies a signature before doing anything.

  • Postfix, if used, listens on loopback only.

  • DMARC moves from p=none to enforcement once reports are clean, so nobody can forge your domain.

  • Audit logs show who read the secret; set an alert on unusual AccessSecretVersion volume.

Troubleshooting

Symptom Likely cause What to check

Connection to the provider hangs or times out on port 25

Outbound port 25 is blocked from Google Cloud.

Use 587 (STARTTLS), 465 (implicit TLS) or the HTTPS API. Test with nc -vz smtp.sendgrid.net 587 from the VM or a Cloud Shell session.

403 or 401 from the provider API

Wrong or revoked key, or a key without Mail Send.

Create a new key with the Mail Send scope, add it as a new secret version and redeploy.

notifier returns 403 Forbidden to Pub/Sub pushes

The push identity lacks roles/run.invoker, or the audience does not match the service URL.

gcloud run services get-iam-policy notifier --region=us-central1; compare the subscription’s audience with the URL.

Container fails to start with "Permission denied on secret"

The runtime service account cannot access the secret.

Grant roles/secretmanager.secretAccessor on sendgrid-api-key to notifier-runtime.

Messages accepted but land in spam

DKIM or SPF not aligned, a new domain or IP with no reputation, a missing DMARC record.

Read Authentication-Results in a received message; send to a mailbox-testing service; warm up a new IP.

"I never received it", API returned 202

Recipient on the suppression list, or the message bounced after acceptance.

Look at the provider’s activity feed and the email-events topic for the message identifier.

Duplicate emails

Pub/Sub redelivery after a slow acknowledgement.

Acknowledge by returning 2xx quickly, raise the acknowledgement deadline, and deduplicate on the order identifier.

Workspace relay rejects with 550 5.7.1

The rule does not allow the sender or the source IP, or the TLS requirement is not met.

Re-check the Gmail SMTP relay service rule, and allow-list the Cloud NAT egress address.

What changed recently, and what older guides still show

  • SendGrid free plan retired on 2025-05-27; new accounts since 2025-03-25 receive a 60-day trial. Older guides promise a permanent free tier.

  • Gmail and Yahoo bulk-sender rules applied from February 2024, with stricter enforcement from November 2025.

  • Secrets. Older sample code puts the provider key in a plain environment variable set with --set-env-vars; the current approach is a Secret Manager reference with --set-secrets.

  • Public functions. Older recipes deploy the sending function with --allow-unauthenticated, creating an open email relay; the current approach is an authenticated invoker.

  • Downloaded service-account keys. Older examples authenticate with a JSON key file; the current approach is attached service accounts, Workload Identity Federation and impersonation, and key creation is blocked by default in organisations created since 2024-05-03.

  • Cloud Functions are now Cloud Run functions; the mail code is unchanged, but the deploy commands differ (see Cloud Run Functions and Events).

Clean up

Remove what this page created, in dependency order:

# Subscriptions and topics
gcloud pubsub subscriptions delete notifier-push --quiet
gcloud pubsub topics delete email-events --quiet
# Leave the orders topic if other pages use it; otherwise:
# gcloud pubsub topics delete orders --quiet

# Cloud Run services
gcloud run services delete notifier --region="$REGION" --quiet
gcloud run services delete email-webhook --region="$REGION" --quiet

# DNS records added for authentication
gcloud dns record-sets delete "s1._domainkey.${DOMAIN}." --zone="$ZONE" --type=CNAME
gcloud dns record-sets delete "s2._domainkey.${DOMAIN}." --zone="$ZONE" --type=CNAME
gcloud dns record-sets delete "em1234.${DOMAIN}." --zone="$ZONE" --type=CNAME
gcloud dns record-sets delete "_dmarc.${DOMAIN}." --zone="$ZONE" --type=TXT

# IAM bindings and service accounts
gcloud secrets remove-iam-policy-binding sendgrid-api-key \
  --member="serviceAccount:notifier-runtime@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/secretmanager.secretAccessor"
gcloud iam service-accounts delete "notifier-runtime@${PROJECT_ID}.iam.gserviceaccount.com" --quiet
gcloud iam service-accounts delete "pubsub-pusher@${PROJECT_ID}.iam.gserviceaccount.com" --quiet

# Secret (versions are destroyed with it; set a delayed-destruction period beforehand if you want a safety net)
gcloud secrets delete sendgrid-api-key --quiet

Also revoke the API key (and the webhook key) in the provider’s console and cancel or downgrade the plan or Marketplace subscription, because deleting Google Cloud resources does not stop the provider’s billing. In the Postfix case, delete /etc/postfix/sasl_passwd and the VM (see Compute Engine). For a throwaway project, deleting the project removes all Google Cloud resources after the 30-day pending-deletion window.