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 ( |
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 ( |
HTTPS API and SMTP ( |
HTTPS API v3.1 and SMTP ( |
SMTP only ( |
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
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
Derived from Enabling and disabling services.
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
-
Open Marketplace, search for SendGrid and open the Twilio SendGrid listing.
-
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.
-
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)"
Derived from Sending email with SendGrid.
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
-
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. -
SendGrid shows three CNAME records (for example a
em1234mail-from host and twos1._domainkeyands2._domainkeyDKIM hosts) pointing at SendGrid-owned names. Copy them. -
In the Google Cloud console open Network services → Cloud DNS, open the zone for
example.comand click Add standard. -
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. -
Back in SendGrid click Verify. Verification usually completes within minutes, but can take hours.
-
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
-
Open Security → Secret Manager and click Create secret.
-
Enter the name
sendgrid-api-key. -
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).
-
Keep Automatically replicate under Replication policy (or pick a regional secret in
us-central1). -
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 |
|---|---|---|
|
Runs the |
|
|
Identity Pub/Sub uses to push to |
|
In the Google Cloud console
-
Open IAM & Admin → Service accounts and click Create service account.
-
Enter
notifier-runtime, click Create and continue, skip project-level roles and click Done. -
Repeat for
pubsub-pusher. -
Open Security → Secret Manager, select
sendgrid-api-key, open the Permissions tab, click Grant access, enternotifier-runtime@my-project-id.iam.gserviceaccount.comand 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
-
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 servicenotifierand choose the regionus-central1. -
Under Authentication choose Require authentication (do not choose Allow unauthenticated invocations).
-
Expand Container(s), Volumes, Networking, Security. On the Security tab choose the service account
notifier-runtime. -
On the Variables & Secrets tab click Reference a secret, choose
sendgrid-api-key, set Reference method to Exposed as environment variable, nameSENDGRID_API_KEY, versionlatest. -
Add the plain variables
MAIL_FROM=no-reply@example.comandMAIL_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
-
Open the Admin console at
admin.google.comand go to Apps → Google Workspace → Gmail → Routing. -
Under SMTP relay service click Configure (or Add another rule).
-
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. -
Optionally restrict by IP: a Cloud NAT address gives Cloud Run or GKE a fixed egress IP you can allow-list.
-
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-UnsubscribeandList-Unsubscribe-Postheaders). 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.comfor transactional andnews.example.comfor 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
notifierthat 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
notifieris deployed with--no-allow-unauthenticated; only the Pub/Sub push identity holdsroles/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=noneto enforcement once reports are clean, so nobody can forge your domain. -
Audit logs show who read the secret; set an alert on unusual
AccessSecretVersionvolume.
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 |
|
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. |
|
The push identity lacks |
|
Container fails to start with "Permission denied on secret" |
The runtime service account cannot access the secret. |
Grant |
Messages accepted but land in spam |
DKIM or SPF not aligned, a new domain or IP with no reputation, a missing DMARC record. |
Read |
"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 |
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 |
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
Derived from Deleting services, Pub/Sub subscriptions and Delete a secret.
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.
Related pages
-
Secret Manager and Cloud KMS — storing, versioning and rotating the provider key.
-
Cloud Run — deploying, authenticating and scaling the
notifier. -
Cloud Run Functions and Events — the event-driven function variant and Eventarc.
-
Messaging and Integration — Pub/Sub subscriptions, retries and dead-letter topics.
-
Cloud DNS and Certificate Manager — hosting the SPF, DKIM and DMARC records.
-
Identity and Access — service accounts and the invoker role.
-
Runtime Configuration — other ways to hand configuration to a service.
-
Configuration and Profiles — Spring Boot mail properties and profiles.
-
Scheduling and Mail — the Quarkus Mailer.
-
Sending Email with Amazon SES and Email with Azure Communication Services — the AWS and Azure counterparts.
References
-
Sending email from an instance: https://docs.cloud.google.com/compute/docs/tutorials/sending-mail
-
Sending email with SendGrid: https://docs.cloud.google.com/compute/docs/tutorials/sending-mail/using-sendgrid
-
Sending email with Mailjet: https://docs.cloud.google.com/compute/docs/tutorials/sending-mail/using-mailjet
-
Workspace SMTP relay: https://knowledge.workspace.google.com/admin/gmail/send-email-from-a-printer-scanner-or-app
-
Email sender guidelines (Gmail): https://support.google.com/a/answer/81126
-
Trigger Email from Firestore extension: https://firebase.google.com/docs/extensions/official/firestore-send-email
-
Cloud Run secrets: https://docs.cloud.google.com/run/docs/configuring/services/secrets
-
Cloud Run service-to-service authentication: https://docs.cloud.google.com/run/docs/authenticating/service-to-service
-
Secret Manager: https://docs.cloud.google.com/secret-manager/docs/overview
-
Pub/Sub push subscriptions: https://docs.cloud.google.com/pubsub/docs/push
-
Cloud DNS records: https://docs.cloud.google.com/dns/docs/records
-
Secret Manager pricing: https://cloud.google.com/secret-manager/pricing
-
Cloud Run pricing: https://cloud.google.com/run/pricing
-
Pub/Sub pricing: https://cloud.google.com/pubsub/pricing