Passa al contenuto principale

ADR-0009: Autenticazione IMAP — OAuth2 first per Gmail e Microsoft 365

Status

Accepted — implementazione obbligatoria dal day 1 della pipeline ingestion email.

Context

Akira ingestisce email da caselle dedicate per più moduli:

  • NOC Trouble Tickets (cap. 16.6): mailbox noc@<dominio> con polling IMAP, match company by sender, creazione TT automatica.
  • Quality Verifier Open Ticket (cap. 49.2.4): risposta a email outbound.
  • Pattern Analyzer / fraud reports (cap. 58.9): mailbox dedicata per ricezione report carrier.

Tutti i provider enterprise hanno dismesso basic auth IMAP o sono in fase di step-down:

  • Google Gmail / Google Workspace: basic auth IMAP disabilitato da maggio 2022. Obbligatorio OAuth2 / XOAUTH2.
  • Microsoft 365 / Exchange Online: basic auth disabilitato per nuovi tenant da ottobre 2022, step-down completato su tenant esistenti nel 2023. Obbligatorio OAuth2 XOAUTH2 con tenant_id.
  • Yahoo Mail: solo app password (legacy ma supportato).
  • Provider IT (Aruba, RegisterMail, Register.it): ancora password legacy IMAP.

Implementare solo basic auth significherebbe escludere la maggior parte dei customer enterprise dal day 1.

Decision

Implementare OAuth2 IMAP day-1 come prima opzione, con fallback password legacy per provider che non lo supportano ancora.

Provider OAuth2 supportati

ProviderMechanismNote
Gmail / Google WorkspaceXOAUTH2Google Cloud Console OAuth app, scope https://mail.google.com/
Microsoft 365 / Exchange OnlineXOAUTH2Azure AD app registration, scope https://outlook.office.com/IMAP.AccessAsUser.All, richiede tenant_id

Provider password legacy (transitorio)

  • Aruba, RegisterMail, Register.it, generic IMAP/SSL.
  • Marcati deprecation roadmap v1.1 — quando vorranno fare onboarding di customer su questi provider, segnalare in UI.

Schema DB — tabella mailboxes

CREATE TABLE mailboxes (
id UUID PRIMARY KEY,
company_id UUID NOT NULL REFERENCES companies(id),
purpose VARCHAR NOT NULL, -- 'noc_tt', 'quality_verifier', 'fraud_reports'
email_address VARCHAR NOT NULL,
imap_host VARCHAR NOT NULL,
imap_port INT NOT NULL DEFAULT 993,
imap_use_ssl BOOLEAN NOT NULL DEFAULT TRUE,

auth_type VARCHAR NOT NULL, -- 'basic_password' | 'oauth2'

-- OAuth2 fields (NULL se basic_password)
oauth_provider VARCHAR, -- 'gmail' | 'microsoft365'
oauth_tenant_id VARCHAR, -- solo microsoft365
oauth_client_id VARCHAR,
oauth_refresh_token_encrypted BYTEA, -- AES-256-GCM
oauth_access_token_cache_encrypted BYTEA,
oauth_token_expires_at TIMESTAMPTZ,

-- Basic password fields (NULL se oauth2)
password_encrypted BYTEA, -- AES-256-GCM, deprecation v1.1

polling_interval_seconds INT NOT NULL DEFAULT 60,
last_poll_at TIMESTAMPTZ,
last_poll_status VARCHAR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),

is_active BOOLEAN NOT NULL DEFAULT TRUE,

CONSTRAINT auth_type_consistency CHECK (
(
auth_type = 'basic_password'
AND password_encrypted IS NOT NULL
AND oauth_refresh_token_encrypted IS NULL
)
OR
(
auth_type = 'oauth2'
AND password_encrypted IS NULL
AND oauth_provider IS NOT NULL
AND (oauth_provider <> 'microsoft365' OR oauth_tenant_id IS NOT NULL)
AND (oauth_refresh_token_encrypted IS NOT NULL OR is_active = FALSE)
)
)
);

Cifratura at-rest

  • Algoritmo: AES-256-GCM.
  • Chiave: ENCRYPTION_KEY dall'env (32 byte base64), gestita via Ansible vault.
  • Nonce: random 12 byte, prepended al ciphertext.
  • Tag GCM: appended al ciphertext.
  • Layout BYTEA: [12B nonce][ciphertext][16B tag].

Token rotation

  • Refresh token Gmail: long-lived, rotation automatica ogni 90gg (cron arq job oauth_token_rotator).
  • Refresh token Microsoft 365: long-lived (default 90gg, configurabile lato Azure AD). Stessa rotation 90gg.
  • Access token cache: refresh on-demand quando expired (Pydantic check oauth_token_expires_at < now() - 60s).

Admin UI flow OAuth2

  1. Admin clicca "Add mailbox" → seleziona provider (Gmail / M365 / Other) e inserisce i campi IMAP.
  2. Se OAuth2, il backend crea subito la riga mailboxes con auth_type='oauth2', oauth_provider, eventuale oauth_tenant_id, password assente, token assente, is_active=false e last_poll_status='pending_authorization'.
  3. Admin clicca "Autorizza" sulla riga pending; la UI chiama POST /api/v1/admin/mailboxes/{id}/oauth/start?provider=....
  4. Backend genera state firmato con mailbox_id e provider, redirecta al provider consent URL. Per Microsoft 365 usa oauth_tenant_id della mailbox se presente.
  5. Callback /api/v1/admin/mailboxes/oauth/callback riceve code, verifica state, scambia per refresh+access token e salva i token cifrati sulla riga esistente.
  6. La mailbox autorizzata diventa attivabile dall'admin. Finché oauth_refresh_token_encrypted è NULL, PATCH is_active=true è bloccato con errore esplicito e il poller non tenta login IMAP.
  7. Test connessione IMAP immediato, mostra esito.

Consequences

Positive

  • Compatibilità enterprise day-1: Gmail Workspace e M365 (90%+ dei tenant business europei).
  • Security: nessuna password plaintext, token revocabili lato provider.
  • Audit: ogni accesso traceable via OAuth app log lato provider.

Negative

  • Setup iniziale complesso: registrare app Google Cloud Console + Azure AD app. Documentation onboarding necessaria.
  • Quote OAuth provider: Google ha quota per refresh token; M365 ha rate limit. Da monitorare con > 50 mailbox.
  • Cron rotation: nuovo job arq da implementare e monitorare.

Neutral

  • Doppio code path (oauth2 vs basic_password) → factory pattern IMAPAuthStrategy.
  • Migration futura: quando vorremo deprecare basic_password, query SELECT count(*) FROM mailboxes WHERE auth_type='basic_password' ci dice quanti customer impattati.

Implementation hints

Librerie Python

  • IMAP client: aioimaplib (async).
  • OAuth2 flow: httpx per token exchange.
  • AES-256-GCM: cryptography package, AESGCM class.

Helper class

class MailboxCredentialResolver:
async def get_imap_auth(self, mailbox_id: UUID) -> tuple[str, str]:
"""Returns (username, auth_string) for aioimaplib login."""
mb = await self.db.fetch_mailbox(mailbox_id)
if mb.auth_type == 'oauth2':
access = await self._refresh_if_needed(mb)
xoauth2 = self._build_xoauth2(mb.email_address, access)
return mb.email_address, xoauth2
else:
pwd = self._decrypt(mb.password_encrypted)
return mb.email_address, pwd

Google Cloud Console setup

  • Progetto: akira-prod.
  • OAuth consent screen: External (se customer hanno Workspace) o Internal.
  • Scope: https://mail.google.com/.
  • Redirect URI: https://akira.example.com/admin/mailboxes/oauth/callback.

Azure AD setup

  • App registration: Akira IMAP Ingestion.
  • API permissions: Office 365 Exchange OnlineIMAP.AccessAsUser.All (delegated) e offline_access.
  • Admin consent richiesto lato tenant customer.

Env vars

ENCRYPTION_KEY=... # base64 32 byte
GOOGLE_OAUTH_CLIENT_ID=...
GOOGLE_OAUTH_CLIENT_SECRET=...
MICROSOFT_OAUTH_CLIENT_ID=...
MICROSOFT_OAUTH_CLIENT_SECRET=...

Monitoring

  • Metric mailbox_oauth_refresh_failed_total{provider=} → alert se > 0 in 5min.
  • Metric mailbox_poll_duration_seconds{mailbox_id=} → SLO < 30s.

Alternatives considered

  • Solo basic password: scartato — Gmail/M365 non lo supportano più, escluderemmo i customer enterprise.
  • App-specific password (Yahoo style): scartato per Gmail/M365 — non supportato sul piano Workspace/Business.
  • MS Graph API invece di IMAP: rivalutabile post-MVP — più ricco di metadata, ma cambia il code path drasticamente. Per ora IMAP unificato OK.
  • Tenant per-customer app registration: scartato — operational nightmare, una sola app multi-tenant è la prassi.
  • Storage refresh token plaintext: scartato per ovvi motivi di security (compromise mailbox = compromise account).