Cosa faceva Tidio, e cosa serviva davvero
Il cliente gestiva la casella principale (una singola casella Google Workspace, tipo info@[dominio-cliente].it) con Tidio: gli operatori vedevano e rispondevano alle email da un'interfaccia condivisa, senza mai avere in mano la password reale della casella. La richiesta era semplice da formulare e meno banale da ricostruire: "possiamo avere la stessa cosa dentro il nostro pannello, senza passare da un servizio esterno?"
Il pattern non è esotico — Tidio, Front e Help Scout fanno esattamente questo: si collegano alla casella reale via API o IMAP e nascondono le credenziali agli operatori. La parte interessante era incastrarlo nello stack già in uso dal pannello: Firebase Realtime Database per i dati, Netlify Functions per la logica server-side, Firebase Cloud Messaging per le notifiche push multi-dispositivo già presenti nel progetto.
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.
Due modi per collegarsi a Gmail, e perché uno solo serviva qui
Con un account Google Workspace ci sono due strade per leggere e inviare posta a nome di una casella, lato server:
- Domain-Wide Delegation: un service account autorizzato dall'amministratore della console Workspace a impersonare qualsiasi casella del dominio, senza consenso interattivo per ognuna. Serve accesso admin a livello di organizzazione, e ha senso solo se devi gestire più caselle o l'intero dominio.
- OAuth2 "app installata": un consenso fatto una volta sola da chi ha accesso reale alla casella specifica, che restituisce un refresh token da salvare lato server. Più semplice da attivare, non richiede privilegi da amministratore di dominio, ma va rifatto se il token viene revocato.
Con una sola casella da collegare, la seconda strada era l'ovvia: niente ruoli amministrativi da coinvolgere, niente impersonificazione di indirizzi che non servono, un solo consenso da fare una tantum.
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.
| Aspetto | Domain-Wide Delegation | OAuth2 app installata |
|---|---|---|
| Chi autorizza | Admin console Workspace | Chi ha accesso alla casella |
| Caselle gestibili | Tutto il dominio | Una per consenso |
| Setup iniziale | Più complesso | Un link, un click |
| Vale per Gmail personale | No, solo Workspace | Sì |
Il flusso OAuth, in breve
Da Google Cloud Console: si abilita la Gmail API, si configura la schermata di consenso con gli scope gmail.readonly e gmail.send, si aggiunge come "utente di test" l'indirizzo che farà il consenso (l'app resta in modalità Testing, niente pubblicazione o verifica Google necessaria per un uso interno), e si crea un client OAuth di tipo "app desktop".
Il client desktop non richiede di registrare un redirect URI: Google autorizza automaticamente http://localhost o http://127.0.0.1 su qualsiasi porta, secondo lo standard per le app installate (RFC 8252). Uno script Node lanciato in locale costruisce il link di consenso, lo si apre in un browser dove si è loggati con l'account che ha accesso reale alla casella, e il terminale restituisce il refresh token — che finisce solo come variabile d'ambiente su Netlify, mai in un repository, mai in una chat.
Punto fermo fin dall'inizio: nessuna password reale doveva mai passare per la chat o per il codice. È proprio il senso di usare OAuth invece delle credenziali dirette — nemmeno chi scrive il codice ha bisogno di vedere la password vera della casella, in nessun momento del processo.
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.
Tre Netlify Functions e una struttura dati
Il backend si è ridotto a tre funzioni serverless, più un modulo condiviso per il refresh del token:
- Polling — una funzione schedulata ogni minuto controlla la posta nuova, la scrive in Firebase RTDB come thread e messaggi, e invia una notifica push agli operatori abilitati. Non tocca mai lo stato "letto" sulla casella reale: quella resta consultabile anche da chi ha ancora accesso diretto, senza conflitti.
- Invio — chiamata dal pannello quando un operatore risponde: costruisce il messaggio MIME con gli header
In-Reply-To/Referencesper mantenere il threading, e lo invia davvero come quella casella, non da un indirizzo diverso. - Allegati — scarica su richiesta i byte reali di un allegato dall'API, perché solo il backend ha l'access token: il browser non può raggiungere Gmail direttamente.
Struttura dati, sullo stesso pattern già in uso altrove nel progetto: un nodo per thread con oggetto/mittente/stato, e un sotto-nodo con i singoli messaggi in ordine cronologico.
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/Referencesheaders 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.
Quando il deploy fallisce per un file che "c'è"
Il primo deploy è andato in errore: una funzione condivisa non veniva trovata durante la build, anche se il file esisteva davvero nel repository. Il progetto era passato di recente da un deploy manuale drag-and-drop a un deploy automatico via Git, e nel frattempo la struttura delle cartelle era cambiata: quel file viveva ora dentro una sottocartella dedicata ai moduli condivisi, non nella cartella principale delle funzioni. L'import puntava ancora al vecchio percorso.
Corretto quello, il build ha continuato a fallire — identico all'errore precedente. Il motivo era più banale di quanto sembrasse: i file corretti erano stati preparati ma non erano ancora arrivati sul repository reale, perché la sostituzione era avvenuta solo in locale senza un push effettivo. Lezione pratica: quando un errore di build persiste identico dopo una correzione, il primo sospetto non deve essere "cos'altro è rotto" ma "il file corretto è davvero su Git?" — aprire il file direttamente dall'interfaccia web del repository, non dall'editor locale, è il modo più rapido per togliersi il dubbio.
Un secondo bug, più curioso: un commento nel codice conteneva la sequenza */1 (parte di un'espressione cron scritta come esempio), che chiudeva prematuramente il blocco di commento /* */ e trasformava il resto del testo in codice eseguito. La sintassi dei commenti non distingue tra "spiegazione" e "codice vero" — un dettaglio facile da dimenticare quando si documenta qualcosa che contiene già simboli speciali.
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.
Il 403 causato da un carattere in più
Invio risposte e download allegati partono dal browser, quindi serve un modo per verificare che la richiesta arrivi davvero dal pannello: un header con un secret condiviso, confrontato lato server con una variabile d'ambiente. Dopo il deploy, entrambe le azioni restituivano un errore 403 Forbidden — sempre lo stesso, anche dopo aver riscritto il secret e cambiato il modo di trasmetterlo (da parametro nell'URL a header HTTP).
Invece di continuare a indovinare, la funzione server ha iniziato a loggare solo la lunghezza normalizzata dei due valori — mai il valore stesso — a ogni tentativo fallito:
if (received.trim() !== expected.trim()) {
console.error(
'Secret non corrispondente.',
'Lunghezza env var (normalizzata):', expected.trim().length,
'— Lunghezza header ricevuto (normalizzata):', received.trim().length,
'— env var vuota:', !expected
);
return { statusCode: 403, body: 'Forbidden' };
}
Il log ha mostrato 38 caratteri lato server contro 37 lato client — un solo carattere di differenza, quasi certamente finito lì durante un copia-incolla tra chat, browser e campo di Netlify (il secret conteneva simboli come %, [, ;, facili da perdere o duplicare per errore). Piuttosto che continuare a cercare quel carattere fantasma, la soluzione più solida è stata generarne uno nuovo, questa volta solo esadecimale — nessuna lettera accentata, nessun simbolo che un terminale o un campo di testo possano interpretare diversamente. Da quel momento, invio e download hanno funzionato al primo tentativo.
Un secret generato a caso solo in caratteri esadecimali (a-f0-9) elimina in radice un'intera categoria di bug: niente spazi, niente maiuscole/minuscole ambigue, niente simboli che cambiano significato tra URL, header e terminale.
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:
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.
Far sembrare una mail una mail, non una tabella del pannello
Una volta funzionante il flusso, il vero lavoro di dettaglio è stato sul rendering. Il pannello aveva già regole CSS globali per l'hover sulle righe delle proprie tabelle dati (turni, ferie) — ma quelle regole si applicavano a qualsiasi <tr> della pagina, comprese le tabelle annidate che moltissimi template email usano per il layout. Il risultato: passare il mouse su un'email HTML faceva comparire barre blu di hover pensate per tutt'altro contesto. La correzione non ha toccato le regole globali, che restano valide per il resto del pannello — sono state semplicemente neutralizzate solo dentro il contenitore del contenuto email, con selettori più specifici.
Un problema simile per il colore del testo: alcune email in solo testo non impostano un colore esplicito, quindi erediscono quello chiaro pensato per lo sfondo scuro del pannello — testo chiaro su sfondo bianco, illeggibile. Forzare un colore di default scuro sul contenitore (senza !important, per non rompere email che i colori li impostano davvero) ha risolto senza intaccare i casi già corretti.
L'ultimo pezzo riguardava le immagini: firme e loghi aziendali sono spesso incorporati come allegati con un riferimento cid: (Content-ID) invece di un URL remoto — un meccanismo che un normale client email risolve da solo, ma che un browser non sa interpretare fuori da quel contesto. La funzione di polling ora rileva questi riferimenti, scarica l'allegato incorporato dalla stessa risposta API, e lo converte in un data URI base64 inserito direttamente nell'HTML salvato — niente più icone di immagine rotta.
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.
Dov'è arrivato, e cosa resta aperto
Il modulo oggi vive dentro il pannello come una voce di menu qualsiasi: lista thread, conversazione, risposta con threading corretto, allegati scaricabili, notifiche push quando arriva posta nuova. La casella reale resta sempre e solo accessibile a chi aveva già le credenziali Workspace — il refresh token vive esclusivamente come variabile d'ambiente lato server, mai in un client, mai in un repository pubblico.
Un punto resta apertamente rimandato: le notifiche push per la posta nuova arrivano oggi solo a un piccolo elenco di operatori abilitati lato server, anche se la lettura e la risposta sono visibili a tutto il team — una scelta prudente per non intasare tutti di notifiche per ogni email, da rivedere se il volume o le esigenze del team cambiano.
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.
Domande frequenti
Si può leggere e rispondere a una casella Gmail senza mai conoscere la password reale?
Sì, con OAuth2: chi ha accesso reale alla casella fa un consenso una tantum su Google, e l'app riceve un refresh token — non una password — che vive solo come variabile d'ambiente lato server. È lo stesso principio usato da strumenti come Tidio, Front o Help Scout.
Serve la Domain-Wide Delegation per collegare una sola casella Gmail?
No. Serve solo quando un'app deve impersonare più caselle di un intero dominio Workspace tramite un service account, e richiede accesso admin alla console. Per una singola casella basta un OAuth2 "diretto" di tipo app desktop, con consenso fatto una volta da chi ha accesso reale a quell'indirizzo.
Perché alcune immagini nelle email non si vedono in un client di lettura personalizzato?
Molte email incorporano le immagini come allegati con un Content-ID (cid:) invece di un URL remoto. Un browser non risolve da solo un riferimento cid:, quindi un'interfaccia personalizzata deve scaricare l'allegato dall'API e convertirlo in un data URI base64 da inserire nell'HTML, altrimenti resta un'icona di immagine rotta.
Come si trova un bug di autenticazione senza esporre il secret nei log?
Confrontando solo le lunghezze normalizzate (dopo trim) del valore atteso e di quello ricevuto, e loggando esclusivamente quei due numeri. Se le lunghezze non coincidono, è un carattere residuo da un copia-incolla; se coincidono ma il confronto fallisce comunque, il valore stesso è diverso. Il secret vero non finisce mai nei log.
Un refresh token Gmail può scadere o smettere di funzionare?
Un refresh token per un'app "installata" non ha una scadenza fissa, ma può essere invalidato se l'utente cambia password, revoca manualmente l'accesso da Google, o se l'app in modalità test supera i 100 refresh token attivi sullo stesso client OAuth. Basta ripetere il consenso una tantum per generarne uno nuovo.
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.