A guide for developers who need to ship contact-form and inquiry email without SMTP passwords—and who care about architecture, deliverability, and honest trade-offs.

Why this guide exists

Many small and mid-size products still send “someone filled out the form” email through a third-party provider, a legacy microservice, or—increasingly—Gmail. Google explicitly discourages “app passwords” for integrations and pushes you toward OAuth2 and the Gmail API. At the same time, stakeholders often want:

  • No shared mailbox passwords in config files
  • One deployable they can reason about (not three tiny services)
  • Email that actually lands in the inbox, not spam
  • A path to hand off to another maintainer without rewriting everything

This article outlines a framework distilled from shipping the same patterns on a production Go service: pluggable mailers (SendGrid vs Gmail), OAuth2 with a refresh token, a one-time token helper, and lessons learned on deliverability.

If you are facing similar constraints, you can treat this as a checklist and adapt the pieces—not copy-paste a single “right” stack.


The constraints (name them early)

Before choosing tools, write down your non-negotiables. Ours looked like this:

ConstraintImplication
Client insists on OAuth, not basic SMTP authGmail API (or SMTP with XOAUTH2), not username/password
standalone inquiry/email microservice was retiredEmail logic was consolidated in the main application
SendGrid may remain as a fallback or for other mailNeed a single interface (SendMail-style) with two implementations
Secrets must not land in gittoken.json, API keys, refresh tokens → env / secret manager + .gitignore
Forms are JSON POSTs from the same originHandler reads body once, validates, sends mail, returns 204 or clear errors

Naming constraints upfront saves you from halfway implementing SMTP + App Passwords and having security push back.


Architecture: one interface, two backends

A small mailer package with an interface keeps the HTTP handlers dumb and testable:

  • Sender interface: one method, e.g. Send(SendParams) error
  • SendParams: From, To, optional Reply-To, BCC, subject, plain body
  • Implementations
    • SendGrid (or Postmark, SES, etc.): good default for transactional mail and domain authentication
    • Gmailgoogle.golang.org/api/gmail/v1 + golang.org/x/oauth2

Switch backends with an environment variable, e.g. MAILER=gmail vs MAILER=sendgrid (or default to SendGrid when unset).


Google Cloud and Gmail API: the minimum viable setup

1. Create (or reuse) a Google Cloud project

Use the Google account that will send mail (or a dedicated Google Workspace / Gmail account your org controls).

2. Enable the Gmail API

In APIs & Services → Library, enable Gmail API.

3. OAuth consent screen

Configure the OAuth consent screen (internal vs external depends on your org). For a server that only uses a single workspace account, you still need a proper client and scopes.

4. OAuth client credentials

Create an OAuth client ID. For a one-time refresh-token flow, a Desktop app client type is often the simplest: you run a small CLI locally, complete the browser consent once, and store the refresh token securely.

5. Scope for sending only

Use the narrowest scope you need. For “send on behalf of the authenticated user,” https://www.googleapis.com/auth/gmail.send is appropriate.

6. Refresh token in production

The long-lived secret is the refresh token, not the client secret alone. Store it as:

  • Environment variable (e.g. GMAIL_REFRESH_TOKEN) in your platform, or
  • A secret manager (AWS Secrets Manager, GCP Secret Manager, etc.)

Never commit token.json or similar to git. Add it to .gitignore and document the one-time CLI for generating it.


One-time OAuth helper (developer ergonomics)

Google’s quickstarts are a good reference; in practice teams wrap a tiny program that:

  1. Reads GMAIL_CLIENT_ID and GMAIL_CLIENT_SECRET from the environment
  2. Builds an auth URL with access_type=offline and prompt=consent (so you get a refresh token)
  3. Accepts the authorization code from stdin
  4. Exchanges it and prints only the refresh token (and optionally writes token.json locally—still gitignored)

Run it once per environment (or when rotating credentials), not on every deploy.


Sending mail in Go (Gmail API)

High-level steps:

  1. Build an oauth2.Config with your client ID, secret, Google’s token endpoint, and redirect URL (e.g. out-of-band for desktop-style flows).
  2. Create an oauth2.Token with only the refresh token set; use Config.Client(ctx, token) so the library refreshes access tokens automatically.
  3. Construct a Gmail service with gmail.NewService(ctx, option.WithHTTPClient(httpClient)).
  4. Build a RFC 2822 plain-text message (From, To, Reply-To, Bcc, Subject, body), base64url-encode it (no padding, per Gmail’s expectations), and call Users.Messages.Send("me", &gmail.Message{Raw: encoded}).

Your HTTP handlers should only assemble SendParams (sanitized fields, consistent copy) and call sender.Send.


Wiring the same mailer to multiple forms

Real sites often have:

  • product inquiry JSON payload (many optional fields)
  • contact form (name, email, subject, message)

Use the same Sender and similar envelope (From name, To, BCC, Reply-To = visitor’s email) for both. Duplicate the email body template only where the fields differ—avoid two entirely different pipelines unless you must.


Deliverability: what actually moved the needle for us

OAuth and correct headers are necessary; they are not always sufficient. After we switched to a recognizable From nameReply-To to the submitter, and a structured, human-readable body (short intro, labeled fields, footer with site URL), messages stopped being filed as spam in tests.

Practical tips:

  • Avoid vague From names like “Auto Email.”
  • Prefer transactional copy (“You received a new inquiry…”) over a raw key-value dump with many empty lines.
  • Ask recipients to mark “not spam” once if testing on a new sender.
  • For mission-critical transactional mail at scale, many teams still use a dedicated provider + verified domain (SPF/DKIM). Gmail can work well; know your volume and risk tolerance.

Configuration cheat sheet (for runbooks)

Document something like this for ops and future you:

VariablePurpose
MAILERgmail or sendgrid (or default)
GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET / GMAIL_REFRESH_TOKENGmail OAuth
SENDGRID_API_KEYSendGrid (when MAILER=sendgrid)
MAIL_FROM_EMAIL / MAIL_FROM_NAMEEnvelope
MAIL_TO_EMAILPrimary inbox
MAIL_BCC_EMAILOptional BCC (e.g. internal visibility)
SENDGRID_DRY_RUNOptional: log without sending (useful in dev)

Keep secrets out of Dockerfiles and manifests in version control; inject at deploy time.


What we intentionally did not do

  • We did not rely on “less secure apps” or shared passwords.
  • We did not keep a long-lived separate microservice solely for email after consolidation—logic lived in the main app behind the same interface.
  • We did not treat “works in Postman” as done without checking browser CORS and OPTIONS on inquiry endpoints if the frontend is on another origin during development.

Closing thought

Integrating Gmail for transactional email is straightforward once OAuth and scopes are demystified. The part that generalizes to other teams is the shape of the solution: narrow scopes, refresh tokens as secrets, a pluggable mailer, one-time developer tooling, and honest notes on deliverability.

If you are implementing something similar, start from constraints, then interface + two implementations, then secrets and runbooks—not from “which SMTP library first.”

Leave a comment