domain teardown orphan cleanup and create mailbox refine, when mailbox is already created / cleanup orphan and github workflow update

This commit is contained in:
ChrispyBacon-dev 2026-09-19 16:41:57 +02:00
parent 674c2437c0
commit dbff6f6449
6 changed files with 138 additions and 23 deletions

View file

@ -13,6 +13,7 @@ on:
- "v*.*.*-*"
paths:
- "dockflare/**"
- ".github/workflows/docker-image.yml"
pull_request:
branches:
- "stable"
@ -21,10 +22,16 @@ on:
- "dockflare-mail"
paths:
- "dockflare/**"
- ".github/workflows/docker-image.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: docker-dockflare-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build_self_hosted:
runs-on: self-hosted
@ -44,7 +51,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
if: github.event_name == 'push'
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -72,7 +79,7 @@ jobs:
with:
context: ./dockflare
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=dockflare
@ -97,7 +104,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
if: github.event_name == 'push'
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -125,7 +132,7 @@ jobs:
with:
context: ./dockflare
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=dockflare

View file

@ -13,6 +13,7 @@ on:
- "v*.*.*-*"
paths:
- "mail-manager/**"
- ".github/workflows/docker-mail-manager.yml"
pull_request:
branches:
- "stable"
@ -21,10 +22,16 @@ on:
- "dockflare-mail"
paths:
- "mail-manager/**"
- ".github/workflows/docker-mail-manager.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: docker-mail-manager-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build_self_hosted:
runs-on: self-hosted
@ -44,7 +51,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
if: github.event_name == 'push'
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -72,7 +79,7 @@ jobs:
with:
context: ./mail-manager
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=dockflare-mail-manager
@ -97,7 +104,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
if: github.event_name == 'push'
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -125,7 +132,7 @@ jobs:
with:
context: ./mail-manager
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=dockflare-mail-manager

View file

@ -13,6 +13,7 @@ on:
- "v*.*.*-*"
paths:
- "webmail/**"
- ".github/workflows/docker-webmail.yml"
pull_request:
branches:
- "stable"
@ -21,10 +22,16 @@ on:
- "dockflare-mail"
paths:
- "webmail/**"
- ".github/workflows/docker-webmail.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: docker-webmail-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build_self_hosted:
runs-on: self-hosted
@ -44,7 +51,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
if: github.event_name == 'push'
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -72,7 +79,7 @@ jobs:
with:
context: ./webmail
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=dockflare-webmail
@ -97,7 +104,7 @@ jobs:
uses: docker/setup-buildx-action@v4
- name: Log in to Docker Hub
if: github.event_name == 'push'
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -125,7 +132,7 @@ jobs:
with:
context: ./webmail
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=dockflare-webmail

View file

@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file.
- **Missing Access policies on fresh setups:** Fixed a regression from the system-policy work back in v3.0.x. On a fresh install the two built-in policies (`public-default-bypass` and `authenticated-default`) could fail to be created on Cloudflare because setup runs before the config is written, and the stale local-only reference then made DockFlare skip them on every later start - so label-created Access Apps came up with zero policies and every login was denied, with nothing useful in the logs. DockFlare now verifies both policies against Cloudflare on startup and creates or re-links them when they're missing. Also fixed a duplicate `public-default-bypass` being created each time the Access Policies page loaded, and a bad `dockflare.access.policy` value no longer silently strips an existing policy - it logs an error and leaves the app untouched. While in there, `cf_policy_id` and `cloudflare_policy_id` are now kept in sync. Thanks [@PauloJf](https://github.com/PauloJf) for the report and repro ([#399](https://github.com/ChrispyBacon-dev/DockFlare/issues/399)).
- **Inbound email blocked when Webmail sits behind Cloudflare Access:** If your webmail hostname (e.g. `mail.example.com`) is protected by a Cloudflare Access application, the inbound email worker's webhook to `/api/v1/webhook/inbound` was being caught by the Access login redirect and never reached mail-manager. Outbound worked fine, but received mail just piled up in the R2 `temp_cache` with nothing in the logs, and the retry cron hit the same wall. DockFlare now creates a path-scoped `DockFlare Mail Webhook Bypass` Access application (bypass policy, exact path only) when you set up a domain, and on startup it detects existing installs where the webhook is behind Access and fixes them automatically. Only that one path is opened - the webhook is still authenticated by the per-domain HMAC signature, and your webmail UI stays protected. The auto-fix needs `Account > Access: Apps and Policies: Edit` on the API token (documented in the Email prerequisites).
- **Webmail dependency refresh and build hygiene:** Upgraded the webmail frontend from Vite 5 to 8, Tiptap 2 to 3, `@vitejs/plugin-vue` 5 to 6, `vite-plugin-pwa` 0.21 to 1.3, and workbox 7.3 to 7.4. This clears all 35 `npm audit` findings (including one high-severity Vite advisory) — both webmail and DockFlare now audit clean. The rich-text composer was adjusted for Tiptap 3 (named `TextStyle` import; Link and Underline now ship inside StarterKit), the Vite config now uses `import.meta.dirname`, and the webmail build image moved to Node 22. Also fixed a long-standing build-hygiene issue: `vue-tsc` was emitting compiled `.js`/`.js.map` files next to the sources and they had been committed, so `noEmit` is now set, the 102 committed artifacts were removed, and the output is git-ignored.
- **Email domain lifecycle fixes:** Three email issues surfaced while wiring up the webhook bypass. Tearing down a domain (or "teardown all") now removes the `DockFlare Mail Webhook Bypass` Access application it created instead of leaving it orphaned - and it skips the deletion if another configured domain still shares that webmail hostname. Creating a mailbox for an address that already has a routing rule (a leftover from a failed attempt, or one added manually) no longer fails with `409 Duplicated Zone rule`; DockFlare now finds the existing rule and updates it instead of blindly creating a new one. And quota KV namespace creation is idempotent again - it checks for an existing namespace before creating, so the `code 10014 "namespace already exists"` error no longer appears on every startup.
## [v3.1.5] - 2026-09-01

View file

@ -267,6 +267,24 @@ def set_worker_cron(script_name, cron_expressions):
def delete_worker(script_name):
return cf_api_request('DELETE', f'/accounts/{config.CF_ACCOUNT_ID}/workers/scripts/{script_name}')
def _find_email_routing_rule(zone_id, address):
try:
response = list_email_routing_rules(zone_id)
rules = response.get('result') or []
except Exception as e:
logging.warning(f"Could not list email routing rules for zone {zone_id}: {e}")
return None
target = str(address or '').strip().lower()
for rule in rules:
for matcher in rule.get('matchers') or []:
if (
matcher.get('type') == 'literal'
and matcher.get('field') == 'to'
and str(matcher.get('value', '')).strip().lower() == target
):
return rule
return None
def create_email_routing_rule(zone_id, address, worker_name):
data = {
"matchers": [{"type": "literal", "field": "to", "value": address}],
@ -274,7 +292,21 @@ def create_email_routing_rule(zone_id, address, worker_name):
"enabled": True,
"name": f"DockFlare: {address}"
}
return cf_api_request('POST', f'/zones/{zone_id}/email/routing/rules', json_data=data)
existing = _find_email_routing_rule(zone_id, address)
if existing and existing.get('id'):
logging.info(f"Email routing rule for {address} already exists; updating it instead of creating")
return cf_api_request('PUT', f'/zones/{zone_id}/email/routing/rules/{existing["id"]}', json_data=data)
try:
return cf_api_request('POST', f'/zones/{zone_id}/email/routing/rules', json_data=data)
except Exception as e:
error_text = str(e).lower()
if '409' not in error_text and 'duplicated' not in error_text and 'already exists' not in error_text:
raise
existing = _find_email_routing_rule(zone_id, address)
if not existing or not existing.get('id'):
raise
logging.info(f"Email routing rule for {address} already exists (409); updating it")
return cf_api_request('PUT', f'/zones/{zone_id}/email/routing/rules/{existing["id"]}', json_data=data)
def delete_email_routing_rule(zone_id, rule_id):
return cf_api_request('DELETE', f'/zones/{zone_id}/email/routing/rules/{rule_id}')
@ -356,19 +388,12 @@ def create_kv_namespace(title):
return res.get('result', {}).get('id')
def get_or_create_kv_namespace(title):
try:
ns_id = create_kv_namespace(title)
if ns_id:
return ns_id
except Exception:
pass
def _list_kv_namespaces_tagged(title):
page = 1
while True:
res = cf_api_request('GET', f'/accounts/{config.CF_ACCOUNT_ID}/storage/kv/namespaces',
params={'per_page': 100, 'page': page})
results = res.get('result') or []
for ns in results:
for ns in res.get('result') or []:
if ns.get('title') == title:
return ns.get('id')
info = res.get('result_info', {})
@ -377,6 +402,18 @@ def get_or_create_kv_namespace(title):
page += 1
return None
def get_or_create_kv_namespace(title):
existing = _list_kv_namespaces_tagged(title)
if existing:
return existing
try:
ns_id = create_kv_namespace(title)
if ns_id:
return ns_id
except Exception as e:
logging.warning(f"Could not create KV namespace '{title}': {e}")
return _list_kv_namespaces_tagged(title)
def update_kv_entry(namespace_id, key, value_dict):
url = f"{config.CF_API_BASE_URL}/accounts/{config.CF_ACCOUNT_ID}/storage/kv/namespaces/{namespace_id}/values/{key}"
headers = {"Authorization": f"Bearer {config.CF_API_TOKEN}", "Content-Type": "text/plain"}
@ -568,3 +605,35 @@ def ensure_webhook_access_bypass(webmail_hostname):
)
return None
def delete_webhook_access_bypass(webmail_hostname):
account_id = getattr(config, 'CF_ACCOUNT_ID', None)
if not account_id or not webmail_hostname:
return False
host = _normalize_access_destination(webmail_hostname)
path = WEBHOOK_ACCESS_PATH
try:
response = cf_api_request('GET', f'/accounts/{account_id}/access/apps', params={"per_page": 100})
apps = response.get('result') or []
except Exception as e:
logging.warning(f"Could not list Access apps to remove webhook bypass for {host}: {e}")
return False
existing = _find_webhook_bypass_app(apps, host, path)
if not existing:
return False
app_uuid = existing.get('id') or existing.get('uid')
if not app_uuid:
return False
try:
cf_api_request('DELETE', f'/accounts/{account_id}/access/apps/{app_uuid}')
logging.info(f"Deleted Cloudflare Access webhook bypass app for {host}{path}")
return True
except Exception as e:
logging.warning(f"Could not delete Access webhook bypass app {app_uuid}: {e}")
return False

View file

@ -142,7 +142,7 @@ def setup_email_domain():
quota_kv_ns_id = None
try:
quota_kv_ns_id = email_manager.create_kv_namespace(
quota_kv_ns_id = email_manager.get_or_create_kv_namespace(
f"dockflare-quota-{zone_name.replace('.', '-')}"
)
except Exception as e:
@ -301,6 +301,18 @@ def teardown_domain():
current_app.config['EMAIL_ENABLED'] = False
save_email_config(email_cfg)
webmail_hostname = _get_webmail_hostname()
teardown_host = webmail_hostname or f"mail.{zone_name}"
remaining_hosts = {
webmail_hostname or f"mail.{remaining_domain}"
for remaining_domain in email_cfg.get('domains', {}).keys()
}
if teardown_host not in remaining_hosts:
try:
email_manager.delete_webhook_access_bypass(teardown_host)
except Exception as e:
errors.append(f"Webhook bypass: {e}")
_restart_mail_container()
return jsonify({'success': True, 'errors': errors})
@ -313,6 +325,12 @@ def teardown_all():
email_cfg = config.EMAIL_CONFIG.copy()
errors = []
webmail_hostname = _get_webmail_hostname()
webhook_hosts = {
webmail_hostname or f"mail.{domain}"
for domain in email_cfg.get('domains', {}).keys()
}
for domain, domain_cfg in list(email_cfg.get('domains', {}).items()):
errors.extend(_teardown_domain_remote(domain, domain_cfg))
@ -335,6 +353,12 @@ def teardown_all():
current_app.config['EMAIL_ENABLED'] = False
save_email_config(email_cfg)
for host in webhook_hosts:
try:
email_manager.delete_webhook_access_bypass(host)
except Exception as e:
errors.append(f"Webhook bypass ({host}): {e}")
_restart_mail_container()
return jsonify({'success': True, 'errors': errors})