Files
factory/vibe/guidebooks/cms/zoho-email.md
Gabriel Radureau 548dacfc44 docs(vibe): add tools/ and cms/ guidebooks
Two code-grounded tree-docs guidebooks under vibe/guidebooks/, drilling into the
lab-ecosystem 02-tools and 03-cms pages (bidirectional):

- tools/  : hub + components.md (Vault+VSO, Prometheus, Grafana, CrowdSec,
  pgbouncer, Redis/KeyDB, Plausible, ClickHouse; pgcat/tool as Tier-2) +
  secrets-and-vso.md (Vault engines/auth, the app_roles/app_policy modules =
  the <app> join-key machinery, VSO CRDs, secret-paths inventory).
- cms/    : hub + site.md (Nuxt + dual Pages/k3s deploy) + cloudflare.md
  (zone via OVH->CF, Pages, cloudflared tunnel, Turnstile, R2 state) +
  zoho-email.md (OAuth, MX/SPF/DKIM/DMARC/BIMI, the 7 aliases).

Sibling-repo code linked via full gitea URLs; vibe-internal links bidirectional.
Reconciled the cloudflared tunnel token path to kvv2 cms/cloudflared (the chart
VaultStaticSecret is kv-v2; the kvv1 tofu reference is a commented-out stub).
6 mermaid diagrams MCP-validated; zero dead links. Lab Cartographer cohort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:41:15 +02:00

11 KiB

vibe > Guidebooks > CMS > Zoho email

Zoho email

Status: Active Last Updated: 2026-06-23 Upstream: CMS · Cloudflare Downstream: secrets-and-vault concept Related: lab-ecosystem 03 · cms · tofu CI flow · safe-env ADR · safe-env PRD

Email for arcodange.fr is hosted at Zoho Mail (EU region) and provisioned entirely from OpenTofu. There is no Zoho web-console click-ops in the steady state: the same tofu apply that owns the Cloudflare zone also drives the Zoho REST API to read the organization, publish the DNS records mail delivery depends on, and create one mailbox alias + one Inbox sub-folder per address. This page lives under cms/cloudflare/zoho/, a sub-module of the Cloudflare tofu root.

Caution

DNS/email changes here are high-stakes and slow to fail. A wrong MX, SPF, DKIM, or DMARC record silently degrades or breaks arcodange.fr deliverability for days — receivers cache TTLs, reputation decays, and there is no synchronous error to catch in CI. DMARC is published as p=reject, so a broken SPF/DKIM alignment means conforming receivers drop legitimate mail outright rather than quarantine it. This is a prime motivation for the safe environment: changes to this module must be validated plan-only against a throwaway/clone zone, never iterated directly against the live arcodange.fr zone. See the safe-env ADR and the safe-env PRD.

How the module is wired

The Cloudflare root (cloudflare/iac.tf) instantiates module "zoho", passing it the live zone and domain plus the OAuth client credentials:

Input Source Purpose
domain_name ovh_domain_name.arcodange_fr.domain_name the domain to manage (arcodange.fr)
dns_zone_id cloudflare_zone.arcodange_fr.id Cloudflare zone the DNS records land in
zoho_client_id var.ZOHO_CLIENT_ID (Vault kvv1/zoho/self_client) OAuth2 self-client id
zoho_client_secret var.ZOHO_CLIENT_SECRET (Vault kvv1/zoho/self_client) OAuth2 self-client secret

In CI the secrets are injected by the vault-action step in .gitea/workflows/cloudflare.yaml, which maps the whole kvv1/zoho/self_client KV-v1 secret into both the shell env (ZOHO_*, consumed by the helper scripts) and the tofu vars (TF_VAR_ZOHO_*, consumed by config.tf):

kvv1/zoho/self_client * | ZOHO_ ;
kvv1/zoho/self_client * | TF_VAR_ZOHO_ ;

OAuth2: client-credentials flow

Zoho is a self-client (machine-to-machine) integration on the EU datacenter — every host is *.zoho.eu / accounts.zoho.eu. Authentication uses the OAuth2 client_credentials grant; there is no interactive user consent in the running flow (a commented device-code flow remains in .env as historical bootstrap).

The token is minted in config.tf via a data "http" POST to https://accounts.zoho.eu/oauth/v2/token with grant_type=client_credentials and the comma-joined scope list. The bearer is then folded into an Authorization: Zoho-oauthtoken <token> header (local.auth_headers) reused by every subsequent read.

Scope Access Why it is needed
ZohoMail.partner.organization.READ READ (org) resolve the org ZOID
ZohoMail.organization.accounts.READ READ (accounts) find the super-admin account id / zuid
ZohoMail.organization.accounts.UPDATE UPDATE (accounts) add / remove email aliases
ZohoMail.organization.domains.READ READ (domains) fetch the domain verification code + DKIM public key
ZohoMail.folders.ALL ALL (folders) list and create per-alias Inbox sub-folders

Lookup chain (each step feeds the next):

  1. GET https://mail.zoho.eu/api/organizationlocal.org, from which zoid builds local.api_prefix = https://mail.zoho.eu/api/organization/<zoid>.
  2. GET {api_prefix}/domains/{domain_name} (dns.tf) → local.domain, exposing CNAMEVerificationCode and dkimDetailList[0].publicKey.
  3. GET {api_prefix}/accounts (email_aliases.tf) → the single iamUserRole == "super_admin" account, giving its accountId and zuid.

DNS records published on the Cloudflare zone

modules/zoho_mail_dns materialises every cloudflare_dns_record Zoho mail needs onto the live zone. The DKIM key and verification code are read live from the Zoho domain API (step 2 above) and passed in as module inputs, so the records always track what Zoho actually expects. All records use TTL 3600 and apply to the apex (@) unless noted.

Name Type Value Purpose
@ TXT "zoho-verification=<CNAMEVerificationCode>.zmverify.zoho.eu" proves domain ownership to Zoho
@ MX mx.zoho.eu (priority 10) primary inbound mail exchanger
@ MX mx2.zoho.eu (priority 20) secondary mail exchanger
@ MX mx3.zoho.eu (priority 50) tertiary mail exchanger
@ TXT "v=spf1 include:zohomail.eu ~all" SPF: authorise Zoho to send for the domain
zmail._domainkey TXT "<dkim_public_key>" (from dkimDetailList[0].publicKey) DKIM public key for outbound signing
_dmarc TXT "v=DMARC1; p=reject; rua=mailto:arcodange@gmail.com; ruf=mailto:arcodange@gmail.com; sp=reject; adkim=r; aspf=r; pct=100" DMARC policy: reject non-aligned mail, 100% coverage, aggregate+forensic reports to arcodange@gmail.com
default._bimi TXT "v=BIMI1; l=https://arcodange.fr/.well-known/logo.svg; avp=brand;" BIMI: display the brand logo beside authenticated mail (created only when bimi_logo_url != null)

Warning

The DMARC policy is the strictest tier: p=reject and sp=reject (subdomains) with relaxed alignment (adkim=r, aspf=r) and pct=100. There is no quarantine grace band — any message that fails both SPF and DKIM alignment is rejected by conforming receivers. Validate SPF/DKIM correctness in the safe environment before touching the live _dmarc or apex records.

Email aliases

Seven addresses are defined as a single map in email_aliases.tf (local.email_aliases). Each is provisioned twice against the super-admin mailbox: as an email alias on the account, and as a matching Inbox sub-folder so mail to that address can be filtered into its own folder.

Alias (@arcodange.fr) Display name Purpose
bonjour Service Bonjour commercial / sales
bureaux Bureaux Arcodange official bodies (URSSAF, administration)
contact Premier Contact website contact form
helloworld ✅ Arcodange 🏹💻🪽 social networks, newsletter
analytics Analytics 📊🔍 social networks, newsletter
books Accounting 📒🧮 accounting / bookkeeping
abonnements Abonnements 📱🤖 subscriptions (phone, AI, services)

Provisioning is imperative-inside-declarative: each alias is a terraform_data resource whose triggers_replace watches whether the alias/folder is already present, and whose local-exec provisioners shell out to zoho_api_call.sh on create and destroy:

  1. Alias createPUT {api_prefix}/accounts/{zuid} with mode=addEmailAlias, scope ZohoMail.organization.accounts.UPDATE; fails fast if the response contains OPERATION_NOT_PERMITTED.
  2. Alias destroy — same endpoint with mode=deleteEmailAlias (the bare local-part, split off the alias:display key).
  3. Folder createPOST /api/accounts/{accountId}/folders with parentFolderId = the resolved Inbox folder id, scope ZohoMail.folders.ALL.
  4. Folder destroy — looks the folder id up by name, DELETEs it, then also sweeps the corresponding /Trash/<name> (or /Trash/Inbox_<name>) folder Zoho leaves behind.

Note

terraform_data + local-exec is used because aliases and folders are Zoho-side mutations with no first-class Terraform provider. The triggers_replace = { missing = !contains(...) } guard makes the apply idempotent: the provisioner only re-runs when the alias/folder is genuinely absent, so a clean plan is a no-op rather than a re-create.

Helper scripts

Both scripts live beside the tofu and are invoked from local-exec. They share the OAuth client env vars (ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_TOKEN_ENDPOINT) injected from Vault.

Script Role
zoho_api_call.sh Thin HTTP wrapper. Parses --endpoint, -x=<METHOD>, --scope, --data_json / --data_url, and --fail_if_str_in_resp; sources zoho_gen_token.sh, attaches the bearer header, curls the call, fails if a sentinel string (e.g. OPERATION_NOT_PERMITTED) appears, and emits compact JSON via jq.
zoho_gen_token.sh OAuth token cache. gen_zoho_token <scope> returns a cached token from /tmp/zoho_oauth_tokens.cache when fresh, otherwise mints a new client_credentials token and stores it.

zoho_gen_token.sh is lock-based and TTL-bounded:

  • A mutex is taken by mkdir /tmp/zoho_oauth_tokens.lock (atomic dir creation), with up to 10 one-second retries, so concurrent local-exec provisioners don't corrupt the cache. The lock is released on every function exit via trap.
  • Tokens are keyed by scope in /tmp/zoho_oauth_tokens.cache (file mode 600). A token is reused only while younger than 3600 s (~1 h); cleanup_cache prunes expired entries on each call.
  • The wrapper runs cleanup_cache before each request and re-traps it on INT TERM EXIT, so stale tokens never leak past their TTL.

Cross-references