diff --git a/.env.example b/.env.example index 9d0131f..8162f7a 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,8 @@ CONTAINER_NAME=btcpay-mailer HOST_PORT=5000 # Required secrets -WEBHOOK_SECRET=your_super_secret_token_123 +BTCPAY_WEBHOOK_SECRET=secret_from_btcpay_webhook_settings +TEST_TOKEN=generate_with_openssl_rand_hex_32 BTCPAY_API_KEY=your_btcpay_api_key POSTMARK_API_KEY=your_postmark_server_token @@ -22,4 +23,4 @@ DEBUG=false # Test Postmark after deploy (docker exec or Portainer console): # python app.py test-email you@example.com # Or from host: -# curl -X POST "http://localhost:5000/test-email?token=YOUR_WEBHOOK_SECRET&to=you@example.com" +# curl -X POST "http://localhost:5000/test-email?token=YOUR_TEST_TOKEN&to=you@example.com" diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..0d24cd8 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,130 @@ +# btcpaymailer setup guide + +## Overview + +One mailer instance handles **multiple BTCPay stores** on the same server (`payment.nxtgroup.org`). Each store registers the same webhook URL; the app uses `storeId` from each payload to fetch the correct invoice. + +## 1. Postmark + +1. Copy **Server API token** → `POSTMARK_API_KEY` +2. Verify sender → `FROM_EMAIL` (e.g. `billing@nxtgroup.org`) +3. Set `BCC_EMAIL` (comma-separated, optional) + +## 2. BTCPay API key (all stores) + +Use **one account-level API key** with `btcpay.store.canviewinvoices` on every store that will use the mailer. + +1. BTCPay → **Account** → **API keys** → Create +2. Enable `canviewinvoices` for each store +3. Copy token → `BTCPAY_API_KEY` + +Per-store-only keys are not supported unless you extend the app. + +## 3. Portainer registry + +**Registries → Add registry** + +| Field | Value | +|-------|--------| +| URL | `git.nxtgroup.org` | +| Username | `erling` | +| Password | Gitea PAT (`read:package`) | + +## 4. Deploy stack (Swarm + Traefik) + +Wildcard DNS (`*.nxtgroup.org`) is enough — add a Traefik `Host()` label, no extra Cloudflare record required. + +```yaml +version: '3.8' + +services: + btcpay-mailer: + image: git.nxtgroup.org/erling/btcpaymailer:1.0.0 + environment: + BTCPAY_WEBHOOK_SECRET: PASTE_BTCPAY_WEBHOOK_SECRET + TEST_TOKEN: PASTE_RANDOM_TEST_TOKEN + BTCPAY_URL: https://payment.nxtgroup.org + BTCPAY_API_KEY: PASTE_BTCPAY_API_KEY + POSTMARK_API_KEY: PASTE_POSTMARK_TOKEN + FROM_EMAIL: billing@nxtgroup.org + BCC_EMAIL: admin@nxtgroup.org + DEBUG: "true" + networks: + - public + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + labels: + - traefik.enable=true + - traefik.http.routers.btcpay-mailer.rule=Host(`mailer.nxtgroup.org`) + - traefik.http.routers.btcpay-mailer.entrypoints=websecure + - traefik.http.routers.btcpay-mailer.tls.certresolver=cloudflare + - traefik.http.services.btcpay-mailer.loadbalancer.server.port=5000 + restart_policy: + condition: on-failure + +networks: + public: + external: true +``` + +Generate `TEST_TOKEN`: + +```bash +openssl rand -hex 32 +``` + +Set `DEBUG=false` after initial testing. + +## 5. BTCPay webhook (per store) + +Repeat for each store: + +1. Store → **Settings** → **Webhooks** → **Create webhook** +2. **Payload URL:** `https://mailer.nxtgroup.org/btcpay-webhook` (no `?token=`) +3. **Secret:** generate or accept BTCPay’s secret → copy into Portainer as `BTCPAY_WEBHOOK_SECRET` + + Use the **same secret** for all stores **or** one mailer per secret (current app supports one `BTCPAY_WEBHOOK_SECRET` only). Simplest: use the **same webhook secret** when creating each store webhook, or recreate webhooks with one shared secret. + +4. **Events:** `InvoiceReceivedPayment` only +5. **Enabled:** on +6. Save + +The Secret in BTCPay must **exactly match** `BTCPAY_WEBHOOK_SECRET` in Portainer. + +## 6. Test Postmark + +Portainer → container console: + +```bash +python app.py test-email you@example.com +``` + +Or HTTP: + +```bash +curl -X POST "https://mailer.nxtgroup.org/test-email?token=YOUR_TEST_TOKEN&to=you@example.com" +``` + +## 7. Test BTCPay webhook + +1. Create a test invoice with **buyer email** in metadata +2. Pay **part** of the amount (invoice stays `New`, partial paid) +3. Check Portainer **Logs** for `[evaluation]` lines +4. BTCPay → Webhooks → **Deliveries** should show `200` + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| Webhook `401` | `BTCPAY_WEBHOOK_SECRET` ≠ BTCPay webhook Secret | +| Webhook `500` fetch invoice | `BTCPAY_API_KEY` lacks access to that `storeId` | +| `ignored` in logs | Full payment, wrong status, or not partial | +| No email, `400` | Invoice missing `metadata.buyerEmail` | + +## Shutdown + +- BTCPay: disable/delete webhooks +- Portainer: remove stack diff --git a/WEBHOOK.md b/WEBHOOK.md index 9327cf5..4af126f 100644 --- a/WEBHOOK.md +++ b/WEBHOOK.md @@ -3,31 +3,36 @@ ## Endpoint ``` -POST https://mailer.nxtgroup.org/btcpay-webhook?token=YOUR_WEBHOOK_SECRET +POST https://mailer.nxtgroup.org/btcpay-webhook ``` -Auth is the `token` query parameter (must match `WEBHOOK_SECRET`). BTCPay’s optional webhook signing secret is not used by this app. +No query-string token. BTCPay signs each request with the **webhook secret** from the store webhook settings. + +## Authentication + +BTCPay sends header `BTCPAY-SIG`: + +``` +sha256= +``` + +The HMAC is computed over the **raw request body bytes** using the webhook **Secret** shown in BTCPay (same value as `BTCPAY_WEBHOOK_SECRET` in Portainer). ## Disable quickly -1. **BTCPay** → Store → Settings → **Webhooks** → delete or disable the webhook. +1. **BTCPay** → Store → **Webhooks** → delete or disable the webhook. 2. **Portainer** → Stacks → `btcpaymailer` → stop/remove the stack. -No emails are sent while the webhook is disabled or the stack is down. - ## Event subscribed -Only **`InvoiceReceivedPayment`** is processed. All other event types return `200` with `"ignored"` and are silent unless `DEBUG=true`. +Only **`InvoiceReceivedPayment`** is processed. Other events return `200` with `"ignored"` (silent unless `DEBUG=true`). ## Webhook body (BTCPay → mailer) -Typical payload: - ```json { "deliveryId": "abc123", "webhookId": "wh_xyz", - "originalDeliveryId": "abc123", "isRedelivery": false, "type": "InvoiceReceivedPayment", "timestamp": 1717843200, @@ -36,55 +41,33 @@ Typical payload: } ``` -Fields used by this app from the webhook: - -| Field | Use | -|-------|-----| -| `type` | Must be `InvoiceReceivedPayment` | -| `storeId` | Fetch invoice from BTCPay API | -| `invoiceId` | Fetch invoice from BTCPay API | - -The webhook does **not** include full payment amounts or buyer email. The app calls: +The app then fetches the full invoice: ``` GET {BTCPAY_URL}/api/v1/stores/{storeId}/invoices/{invoiceId} ``` -## Invoice fields used (BTCPay API response) +## Send conditions -| Field | Use | -|-------|-----| -| `amount` | Invoice total | -| `amountPaid` | Paid so far | -| `currency` | e.g. `USD`, `BTC` | -| `status` | Must be `New` for partial-payment email | -| `metadata.buyerEmail` | Recipient (required to send) | -| `metadata.orderId` | Shown in email subject/body | - -## Send conditions (all must pass) - -1. `type == InvoiceReceivedPayment` -2. BTCPay API returns invoice successfully -3. `status == "New"` -4. `0 < amountPaid < amount` (partial payment, not zero or full) -5. `metadata.buyerEmail` is set - -Otherwise the handler returns `200` with `"ignored"` or `400` if buyer email is missing on a qualifying partial payment. - -## Logging - -| `DEBUG` | What appears in container logs | -|---------|------------------------------| -| `false` (default) | Evaluation + send result for `InvoiceReceivedPayment` only | -| `true` | Every webhook, full payload, each decision step | - -View logs in **Portainer → Containers → btcpay-mailer → Logs**. +1. Valid `BTCPAY-SIG` signature +2. `type == InvoiceReceivedPayment` +3. BTCPay API returns invoice +4. `status == "New"` +5. `0 < amountPaid < amount` +6. `metadata.buyerEmail` is set ## Response codes | Code | Meaning | |------|---------| -| `401` | Wrong or missing `token` | +| `401` | Missing or invalid `BTCPAY-SIG` | | `400` | Partial payment but no `buyerEmail` | | `500` | BTCPay API or Postmark failure | -| `200` | Ignored, or email sent successfully | +| `200` | Ignored, or email sent | + +## Logging + +| `DEBUG` | Container logs | +|---------|----------------| +| `false` | Evaluation + send result for payment events only | +| `true` | Full payload and every decision step | diff --git a/app.py b/app.py index 486b090..59a83b8 100644 --- a/app.py +++ b/app.py @@ -1,3 +1,5 @@ +import hashlib +import hmac import json import logging import os @@ -9,7 +11,8 @@ from flask import Flask, request, jsonify app = Flask(__name__) # --- Configuration --- -WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "your_secret_token_here") +BTCPAY_WEBHOOK_SECRET = os.getenv("BTCPAY_WEBHOOK_SECRET") +TEST_TOKEN = os.getenv("TEST_TOKEN") BTCPAY_URL = os.getenv("BTCPAY_URL", "https://payment.nxtgroup.org") BTCPAY_API_KEY = os.getenv("BTCPAY_API_KEY") POSTMARK_API_KEY = os.getenv("POSTMARK_API_KEY") @@ -36,6 +39,24 @@ def log_evaluation(message, **fields): logger.info(f"[evaluation] {message}{suffix}") +def get_btcpay_sig_header(): + for key, value in request.headers.items(): + if key.lower() == "btcpay-sig": + return value + return None + + +def verify_btcpay_webhook(body: bytes, signature: str | None, secret: str | None) -> bool: + if not body or not signature or not secret: + return False + expected = "sha256=" + hmac.new( + secret.encode("utf-8"), + body, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(expected, signature) + + def get_html_template(order_id, invoice_id, amount, currency, amount_paid, remaining): return f"""
@@ -166,7 +187,19 @@ def evaluate_partial_payment(invoice_data, invoice_id): @app.route('/btcpay-webhook', methods=['POST']) def btcpay_webhook(): - data = request.json or {} + raw_body = request.get_data() + signature = get_btcpay_sig_header() + + if not verify_btcpay_webhook(raw_body, signature, BTCPAY_WEBHOOK_SECRET): + log_debug("webhook rejected", reason="invalid BTCPAY-SIG signature") + return jsonify({"error": "Unauthorized"}), 401 + + try: + data = json.loads(raw_body) if raw_body else {} + except json.JSONDecodeError: + log_debug("webhook rejected", reason="invalid JSON body") + return jsonify({"error": "Invalid JSON"}), 400 + event_type = data.get('type') log_debug( @@ -179,11 +212,6 @@ def btcpay_webhook(): payload=data, ) - token = request.args.get('token') - if token != WEBHOOK_SECRET: - log_debug("webhook rejected", reason="invalid token") - return jsonify({"error": "Unauthorized"}), 401 - if event_type != 'InvoiceReceivedPayment': log_debug("webhook ignored", reason="not InvoiceReceivedPayment", event_type=event_type) return jsonify({"status": "ignored", "reason": "Not a payment event"}), 200 @@ -246,7 +274,7 @@ def btcpay_webhook(): @app.route('/test-email', methods=['POST']) def test_email(): token = request.args.get('token') - if token != WEBHOOK_SECRET: + if not TEST_TOKEN or token != TEST_TOKEN: return jsonify({"error": "Unauthorized"}), 401 to_email = request.args.get('to') or request.json.get('to') if request.is_json else None diff --git a/docker-compose.yml b/docker-compose.yml index a5413ed..2179dc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,8 @@ services: ports: - "${HOST_PORT:-5000}:5000" environment: - WEBHOOK_SECRET: ${WEBHOOK_SECRET:?Set WEBHOOK_SECRET in .env or environment} + BTCPAY_WEBHOOK_SECRET: ${BTCPAY_WEBHOOK_SECRET:?Set BTCPAY_WEBHOOK_SECRET in .env or environment} + TEST_TOKEN: ${TEST_TOKEN:?Set TEST_TOKEN in .env or environment} BTCPAY_URL: ${BTCPAY_URL:-https://payment.nxtgroup.org} BTCPAY_API_KEY: ${BTCPAY_API_KEY:?Set BTCPAY_API_KEY in .env or environment} POSTMARK_API_KEY: ${POSTMARK_API_KEY:?Set POSTMARK_API_KEY in .env or environment}