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>
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.frdeliverability for days — receivers cache TTLs, reputation decays, and there is no synchronous error to catch in CI. DMARC is published asp=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 livearcodange.frzone. 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):
GET https://mail.zoho.eu/api/organization→local.org, from whichzoidbuildslocal.api_prefix = https://mail.zoho.eu/api/organization/<zoid>.GET {api_prefix}/domains/{domain_name}(dns.tf) →local.domain, exposingCNAMEVerificationCodeanddkimDetailList[0].publicKey.GET {api_prefix}/accounts(email_aliases.tf) → the singleiamUserRole == "super_admin"account, giving itsaccountIdandzuid.
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=rejectandsp=reject(subdomains) with relaxed alignment (adkim=r,aspf=r) andpct=100. There is noquarantinegrace 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_dmarcor 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:
- Alias create —
PUT {api_prefix}/accounts/{zuid}withmode=addEmailAlias, scopeZohoMail.organization.accounts.UPDATE; fails fast if the response containsOPERATION_NOT_PERMITTED. - Alias destroy — same endpoint with
mode=deleteEmailAlias(the bare local-part, split off thealias:displaykey). - Folder create —
POST /api/accounts/{accountId}/folderswithparentFolderId= the resolved Inbox folder id, scopeZohoMail.folders.ALL. - 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-execis used because aliases and folders are Zoho-side mutations with no first-class Terraform provider. Thetriggers_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 concurrentlocal-execprovisioners don't corrupt the cache. The lock is released on every function exit viatrap. - Tokens are keyed by scope in
/tmp/zoho_oauth_tokens.cache(file mode600). A token is reused only while younger than 3600 s (~1 h);cleanup_cacheprunes expired entries on each call. - The wrapper runs
cleanup_cachebefore each request and re-traps it onINT TERM EXIT, so stale tokens never leak past their TTL.
Cross-references
- Parent tofu / zone & Pages: Cloudflare — owns
cloudflare_zone.arcodange_frthat this module writes records into, and thevault-actionCI step that supplies the credentials. - Where these secrets come from: secrets-and-vault concept (
kvv1/zoho/self_client). - How apply runs: tofu CI flow.
- Why a safe environment exists: safe-env ADR · safe-env PRD.