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.

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.

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

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.

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/References per 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.

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.

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:

diagnosi · nessun secret nei log
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.

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.

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.

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.