Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,71 @@ filters:
- `octodns-gitops-drift` - Check for drift between live DNS and local zones
- `octodns-gitops-report` - Query nameservers and show consistency report
- `octodns-gitops-init` - Generate Makefile for dns-zones repositories
- `octodns-gitops-forwardemail` - Reconcile Forward Email domain settings and aliases with per-domain files (see below)

### Forward Email account settings (opt-in)

A dns-zones repo can also own the *account* side of its mail domains at [Forward Email](https://forwardemail.net):
domain settings and aliases, kept in `mail/forward-email/<domain>.yaml` and reconciled through the REST API.
Opt in with a top-level `forward_email:` block in `config.yaml` (ignored by octoDNS, like `delegation:`):

```yaml
forward_email:
token: env/FORWARD_EMAIL_API_TOKEN # env/ reference only, never a literal
directory: ./mail/forward-email # default
defaults: # optional repo-level overrides of the package defaults
settings: {} # API-writable domain fields
expect: {} # read-only fields, drift-checked only
alias: {} # alias field defaults
domains: # the ownership boundary: nothing outside it is ever touched
- example.com
```

One file per claimed domain; everything equal to the resolved defaults is omitted:

```yaml
domain: example.com
settings:
ignore_mx_check: true # only fields the API can write
expect:
has_newsletter: true # FE-staff-set fields we want reported on mismatch
aliases:
- name: hello
recipients: [you@example.org]
- name: '/^([\w\-\.]+)$/' # regex names must be single-quoted
recipients: ['$1@example.org']
is_enabled: false
```

Contract:

- `make mail-plan` is a dry run; `make mail-apply` writes. `DOMAIN=example.com` scopes either.
- Domains are **never created or deleted** from git; a claimed domain missing from the account is an error.
- `PRUNE=1` (the literal `1`; `0`/`false` do not prune) deletes aliases absent from git, inside
claimed domains only, after a second listing agrees with the first. An alias with
`has_imap: true` or stored mail is a mailbox: it is never pruned and blocks the run until it is
added to git **with `has_imap: true`** or removed in the web UI. The same guard covers updates:
a declared alias that merely omits `has_imap` never turns IMAP off on a live mailbox — write
`has_imap: true` to keep it, or `has_imap: false` to turn it off deliberately.
- `make mail-export` writes the files from live state (bootstrap, or re-baseline after a deliberate
web-UI change). A freshly exported file must plan as **zero changes**. Write-only settings already
declared in the file being overwritten are preserved, since the API cannot return them.
- `make mail-drift` compares FE's generated DNS records (DKIM key, `fe-bounces` CNAME, verification
TXT, the DMARC `rua` address) with the repo's zone file, and reports read-only expectation
mismatches. Unless `ignore_mx_check: true`, the apex MX must be exactly FE's two exchangers at
one shared preference — any other exchanger is a finding. The zone file is read from every
YamlProvider the zone's `sources:` names (merged, as octoDNS does); with several YamlProviders
and no such entry the domain is reported as ambiguous rather than unchecked. Exit 1 on any
finding. A claimed domain with no zone file in this repo is a supported layout, but
`mail-drift` then proves nothing about its DNS records: it prints "not checked" and exits 0.
- `aliases:` is always an explicit list (`aliases: []` for a domain with none) and every alias
declares `recipients:`; an absent key is an error, never "empty" — with `PRUNE=1` that would
have meant "delete everything". `recipients: []` is only valid for a mailbox (`has_imap`
resolving to true): an alias that would deliver nowhere is an error. A duplicated YAML key is an
error, never last-wins. `vacation_responder` takes exactly `is_enabled` (bool, required),
`subject` and `message`.
- `max_quota_per_alias` and `bounce_webhook` cannot be read back from the API: they are sent with
every domain update but never produce a diff on their own.

## Quick Start with mise

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ octodns-gitops-init = "octodns_gitops.bootstrap.makefile:main"
octodns-gitops-delegate = "octodns_gitops.cli.delegate:main"
octodns-gitops-dnssec = "octodns_gitops.cli.dnssec:main"
octodns-gitops-ovh-token = "octodns_gitops.cli.ovh_token:main"
octodns-gitops-forwardemail = "octodns_gitops.cli.forward_email:main"

[tool.hatch.version]
# Version is derived from git tags (CalVer YYMM.N, e.g. 2606.1). Untagged
Expand Down
31 changes: 30 additions & 1 deletion src/octodns_gitops/bootstrap/makefile.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

.DEFAULT_GOAL := help

.PHONY: help validate plan apply drift-check report delegate delegate-ns delegate-ds dnssec ovh-token
.PHONY: help validate plan apply drift-check report delegate delegate-ns delegate-ds dnssec ovh-token mail-plan mail-apply mail-drift mail-export

help:
\t@echo ""
Expand Down Expand Up @@ -47,13 +47,21 @@
\t@echo " dnssec - Validate DNSSEC delegation (read-only)"
\t@echo " ovh-token - Request a scoped OVH consumer key (ENV_PREFIX=OVH_...)"
\t@echo ""
\t@echo "Forward Email account settings (opt-in domains under 'forward_email:' in config.yaml):"
\t@echo " mail-plan - DRY-RUN: diff mail/forward-email/<domain>.yaml against the account"
\t@echo " mail-apply - APPLY settings and alias changes (PRUNE=1 also deletes aliases absent from git)"
\t@echo " mail-drift - Check FE-generated DNS records and read-only expectations against the repo"
\t@echo " mail-export - Write per-domain files from live state (bootstrap / re-baseline)"
\t@echo ""
\t@echo "Options:"
\t@echo " ZONE=example.com. - Process only this zone (works with plan, apply, drift-check, report, delegate*, dnssec)"
\t@echo " FORCE=1 - Override 30% safety threshold for apply"
\t@echo " STEP=ns|ds - delegate preview step (default ns)"
\t@echo " SCOPE= - dnssec scope: delegation (default) | all-signed-targets"
\t@echo " ENV_PREFIX= - ovh-token credential env prefix (e.g. OVH_AUTOPS)"
\t@echo " ALLOW_MANUAL_PENDING=1 - delegate-ns/-ds: treat manual (Gandi) zones as informational"
\t@echo " DOMAIN=example.com - mail-*: process only this Forward Email domain (no trailing dot)"
\t@echo " PRUNE=1 - mail-plan/-apply: also delete aliases absent from git (never mailboxes)"
\t@echo " DEBUG=1 - Enable debug output"
\t@echo " QUIET= - Disable quiet mode (unset QUIET)"
\t@echo " LOGGING_CONFIG= - Override logging config file"
Expand Down Expand Up @@ -135,6 +143,27 @@
# Request a least-privilege OVH consumer key (ENV_PREFIX=OVH_AUTOPS)
ovh-token:
\t@octodns-gitops-ovh-token $(if $(ENV_PREFIX),--env-prefix $(ENV_PREFIX),)

# Forward Email - DRY-RUN diff of account settings and aliases (DOMAIN=, PRUNE=1)
mail-plan:
\t@octodns-gitops-forwardemail --config config.yaml $(if $(DOMAIN),--domain $(DOMAIN),) $(if $(filter 1,$(PRUNE)),--prune,)

# Forward Email - APPLY settings and alias changes (never creates or deletes domains)
mail-apply:
\t@echo ""
\t@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
\t@echo " APPLYING FORWARD EMAIL ACCOUNT CHANGES"
\t@echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
\t@echo ""
\t@octodns-gitops-forwardemail --doit --config config.yaml $(if $(DOMAIN),--domain $(DOMAIN),) $(if $(filter 1,$(PRUNE)),--prune,)

# Forward Email - FE-generated DNS records and read-only expectations vs the repo
mail-drift:
\t@octodns-gitops-forwardemail --drift --config config.yaml $(if $(DOMAIN),--domain $(DOMAIN),)

# Forward Email - write mail/forward-email/<domain>.yaml from live state
mail-export:
\t@octodns-gitops-forwardemail --export --config config.yaml $(if $(DOMAIN),--domain $(DOMAIN),)
"""


Expand Down
Loading
Loading