What Tidio did, and what was actually needed

The client managed their main mailbox (a single Google Workspace address, something like info@[client-domain].com) with Tidio: operators could see and reply to emails from a shared interface, without ever holding the mailbox's real password. The request was easy to state and less trivial to rebuild: "can we have the same thing inside our own panel, without going through a third-party service?"

The pattern isn't exotic — Tidio, Front, and Help Scout do exactly this: they connect to the real mailbox via API or IMAP and hide the credentials from operators. The interesting part was fitting it into the panel's existing stack: Firebase Realtime Database for data, Netlify Functions for server-side logic, Firebase Cloud Messaging for the multi-device push notifications already present in the project.

Two ways to connect to Gmail, and why only one made sense here

With a Google Workspace account there are two server-side ways to read and send mail as a given mailbox:

  • Domain-Wide Delegation: a service account authorized by the Workspace admin console to impersonate any mailbox in the domain, with no interactive consent per address. It requires organization-level admin access, and only makes sense if you need to manage multiple mailboxes or the whole domain.
  • "Installed app" OAuth2: a one-time consent from whoever has real access to that specific mailbox, which returns a refresh token to store server-side. Simpler to set up, no domain-admin privileges required, but it has to be redone if the token gets revoked.

With only one mailbox to connect, the second path was the obvious one: no admin roles to involve, no impersonating addresses that weren't needed, a single one-time consent.

AspettoDomain-Wide DelegationOAuth2 app installata
Chi autorizzaAdmin console WorkspaceChi ha accesso alla casella
Caselle gestibiliTutto il dominioUna per consenso
Setup inizialePiù complessoUn link, un click
Vale per Gmail personaleNo, solo Workspace

The OAuth flow, briefly

From Google Cloud Console: enable the Gmail API, configure the consent screen with the gmail.readonly and gmail.send scopes, add the address that will give consent as a "test user" (the app stays in Testing mode, no publishing or Google verification needed for internal use), and create a "desktop app" OAuth client.

A desktop client doesn't require registering a redirect URI: Google automatically authorizes http://localhost or http://127.0.0.1 on any port, per the standard for installed apps (RFC 8252). A Node script run locally builds the consent link, you open it in a browser logged in as the account with real access to the mailbox, and the terminal prints the refresh token — which only ever ends up as an environment variable on Netlify, never in a repository, never in a chat.

🔒

A fixed rule from the start: no real password was ever supposed to travel through chat or code. That's the whole point of OAuth over direct credentials — not even whoever writes the code needs to see the mailbox's actual password, at any point in the process.

Three Netlify Functions and one data structure

The backend came down to three serverless functions, plus a shared module for token refresh:

  • Polling — a function scheduled every minute checks for new mail, writes it into Firebase RTDB as threads and messages, and sends a push notification to allowed operators. It never touches the "read" state on the real mailbox: that stays browsable by anyone with direct access, with no conflicts.
  • Sending — called from the panel when an operator replies: builds the MIME message with In-Reply-To/References headers to keep threading intact, and actually sends it as that mailbox, not from a different address.
  • Attachments — downloads the real bytes of an attachment on demand from the API, because only the backend holds the access token: the browser can't reach Gmail directly.

Data structure, on the same pattern already used elsewhere in the project: one node per thread with subject/sender/status, and a sub-node with individual messages in chronological order.

When the deploy fails over a file that "exists"

The first deploy failed: a shared function couldn't be found during the build, even though the file really did exist in the repository. The project had recently moved from manual drag-and-drop deploys to automatic Git-based deploys, and in the meantime the folder structure had changed: that file now lived inside a dedicated subfolder for shared modules, not the main functions folder. The import still pointed at the old path.

Once fixed, the build kept failing — with the exact same error as before. The reason was more mundane than it looked: the corrected files had been prepared but never actually reached the real repository, because the replacement had only happened locally without an actual push. Practical lesson: when a build error persists identically after a fix, the first suspicion shouldn't be "what else is broken" but "did the fixed file actually make it to Git?" — opening the file straight from the repository's web interface, not the local editor, is the fastest way to settle the doubt.

⚠️

A second, funnier bug: a code comment contained the sequence */1 (part of a cron expression written as an example), which prematurely closed the /* */ comment block and turned the rest of the text into executed code. Comment syntax doesn't distinguish between "explanation" and "actual code" — an easy detail to forget when documenting something that already contains special symbols.

The 403 caused by one extra character

Sending replies and downloading attachments both start from the browser, so there needs to be a way to verify the request really comes from the panel: a header with a shared secret, checked server-side against an environment variable. After deploy, both actions returned a 403 Forbidden — the same error, even after rewriting the secret and changing how it was transmitted (from a URL parameter to an HTTP header).

Instead of guessing further, the server function started logging only the normalized length of the two values — never the value itself — on every failed attempt:

diagnostics · no secret in the logs
if (received.trim() !== expected.trim()) {
  console.error(
    'Secret mismatch.',
    'Env var length (normalized):', expected.trim().length,
    '— Received header length (normalized):', received.trim().length,
    '— env var empty:', !expected
  );
  return { statusCode: 403, body: 'Forbidden' };
}

The log showed 38 characters server-side against 37 client-side — a single character of difference, almost certainly picked up during a copy-paste between chat, browser, and Netlify's input field (the secret contained symbols like %, [, ;, easy to drop or duplicate by mistake). Rather than keep hunting for that phantom character, the more solid fix was generating a new one, this time hex-only — no accented letters, no symbol that a terminal or text field could interpret differently. From that point on, sending and downloading worked on the first try.

💡

A secret generated randomly in hex characters only (a-f0-9) eliminates an entire class of bugs at the root: no spaces, no ambiguous casing, no symbols that change meaning between a URL, a header, and a terminal.

Making an email look like an email, not a panel table

Once the flow worked, the real detail work was in rendering. The panel already had global CSS rules for row hover in its own data tables (shifts, time off) — but those rules applied to any <tr> on the page, including the nested tables so many email templates use for layout. The result: hovering over an HTML email triggered blue hover bars meant for a completely different context. The fix didn't touch the global rules, which stay valid for the rest of the panel — they were simply neutralized only inside the email content container, using more specific selectors.

A similar problem showed up with text color: some plain-text emails don't set an explicit color, so they inherit the light one meant for the panel's dark background — light text on a white background, unreadable. Forcing a dark default color on the container (without !important, so as not to break emails that do set their own colors) fixed it without touching the already-correct cases.

The last piece was images: company signatures and logos are often embedded as attachments with a cid: (Content-ID) reference instead of a remote URL — a mechanism a normal email client resolves on its own, but a browser can't interpret outside that context. The polling function now detects these references, downloads the embedded attachment from the same API response, and converts it into a base64 data URI inserted directly into the saved HTML — no more broken image icons.

When integrating Gmail API directly makes sense (and when it doesn't)

When to use it

  • Shared company inbox where multiple team members need to see and reply to the same emails inside an existing management panel, instead of sharing webmail login credentials.
  • Tight integration with internal data is needed (e.g. linking an email to a specific customer or order), something a generic live-chat tool like Tidio doesn't allow natively.
  • Limited budget for external SaaS tools: direct OAuth2 integration only costs development time, with no recurring subscriptions.

When not to use it

  • Just a simple shared mailbox is needed with no integration with other data: Google Workspace already offers native shared email groups, simpler to set up than a custom API integration.
  • Team without OAuth2 experience: the authorization flow, refresh tokens, and scope management have a learning curve worth accounting for.
  • Advanced helpdesk features needed (tickets, SLAs, assignment queues): a dedicated tool like Zendesk or Freshdesk offers this out of the box.

Alternatives

For those who want to stay in the Google ecosystem without custom development, Google Groups with native shared mailboxes cover the basic use case. For full helpdesk features, a dedicated SaaS tool remains more cost-effective than developing and maintaining a custom integration.

Common mistakes

  • Choosing the wrong OAuth scope: using broader permissions than needed increases the risk surface if the token is ever compromised.
  • A deploy failing over a file that "exists": a case-sensitivity issue or a different relative path between local and Netlify environments can block the deploy in a non-obvious way.
  • A 403 caused by a single extra character in the configuration (e.g. a space or invisible character in an environment variable): always worth checking byte by byte when the error has no obvious cause.

Where it landed, and what's still open

The module now lives inside the panel like any other menu entry: thread list, conversation view, replies with correct threading, downloadable attachments, push notifications when new mail arrives. The real mailbox stays accessible only to whoever already had the Workspace credentials — the refresh token lives exclusively as a server-side environment variable, never in a client, never in a public repository.

One point remains openly postponed: push notifications for new mail currently reach only a small server-side allow-list of operators, even though reading and replying are visible to the whole team — a cautious choice to avoid flooding everyone with a notification per email, worth revisiting if the team's volume or needs change.

Frequently asked questions

Can you read and reply to a Gmail mailbox without ever knowing the real password?

Yes, with OAuth2: whoever has real access to the mailbox gives one-time consent on Google, and the app receives a refresh token — not a password — that only ever lives as a server-side environment variable. It's the same principle tools like Tidio, Front, or Help Scout use.

Do you need Domain-Wide Delegation to connect a single Gmail mailbox?

No. It's only needed when an app must impersonate multiple mailboxes across an entire Workspace domain via a service account, and requires admin access to the console. For a single mailbox, a "direct" desktop-app OAuth2 flow is enough, with one-time consent from whoever has real access to that address.

Why don't some images show up in a custom email reading client?

Many emails embed images as attachments with a Content-ID (cid:) reference instead of a remote URL. A browser can't resolve a cid: reference on its own, so a custom interface has to download that attachment from the API and convert it into a base64 data URI to insert into the HTML, or it stays a broken image icon.

How do you find an authentication bug without exposing the secret in the logs?

By comparing only the normalized lengths (after trimming) of the expected and received values, and logging just those two numbers. If the lengths don't match, it's a leftover character from a copy-paste; if they match but the comparison still fails, the value itself differs. The real secret never ends up in the logs.

Can a Gmail refresh token expire or stop working?

A refresh token for an "installed" app has no fixed expiry, but it can be invalidated if the user changes their password, manually revokes access from Google, or if the app in testing mode exceeds 100 active refresh tokens on the same OAuth client. Repeating the one-time consent generates a new one.