commit c0b8981629ef44cc67cdce165ef271eec35bdf10 Author: wmj Date: Fri Aug 14 08:00:28 2026 +0800 Initial commit: HTTP wrapper around the existing falah-ibaas email connector Gives Supabase Edge Functions (and anything else outside the VPS) a real, authenticated endpoint onto the VPS's already-working internal Postfix relay (the same one Ghost uses in production), via the falah-ibaas EmailConnector that was fully built but never actually deployed. Deployed live at https://nfmailrelay.falahos.my as a Docker Swarm service on the existing Traefik network, TLS via Let's Encrypt, shared-secret bearer auth. Built for Nur Falah's heir notification emails. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eef69ae --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +COPY relay.py . +ENV PORT=8091 +EXPOSE 8091 +# The falah-ibaas email connector (handler.py) is bind-mounted at +# /opt/falah-ibaas/connectors/email at runtime — see docker service create. +CMD ["python3", "relay.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a54f0ae --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# nf-mail-relay + +Small HTTP wrapper around the existing `falah-ibaas` email connector +(`/opt/falah-ibaas/connectors/email` on the Falah OS VPS), so services that +can't reach the VPS's internal Postfix relay directly — like a Supabase Edge +Function running on Supabase's cloud — get a real, internet-reachable, +authenticated endpoint that forwards to it. + +Built for Nur Falah's heir (warith) notification emails, but generic enough +to reuse for anything else that needs "send an email via the VPS's existing +working mail path" without provisioning a third-party provider. + +## Why this exists + +The VPS already has a working SMTP relay — Ghost uses it in production +(`mail__options__host=172.17.0.1:25`, no auth, internal Docker bridge only). +There's also a fully-built but never-deployed `EmailConnector` class at +`/opt/falah-ibaas/connectors/email/handler.py` that wraps `smtplib` with +proper error handling. Neither was reachable from outside the VPS. This +relay is the missing piece: a thin, auth-gated HTTP front door onto that +connector. + +## Endpoints + +- `GET /health` — connector status + send/error metrics +- `POST /send` — send an email + - Header: `X-Relay-Secret: ` + - Body: `{ "to": "...", "subject": "...", "body_text": "...", "body_html": "...", "cc": "...", "bcc": "..." }` + +## Deploy + +```bash +docker build -t nf-mail-relay:latest . + +docker service create \ + --name nf_mail_relay \ + --mount type=bind,source=/opt/falah-ibaas/connectors/email,target=/opt/falah-ibaas/connectors/email,readonly \ + --network falah_traefik-net \ + --constraint node.role==manager \ + --env RELAY_SECRET= \ + --env SMTP_HOST=172.17.0.1 \ + --env SMTP_PORT=25 \ + --env SMTP_FROM=notifications@falahos.my \ + --label traefik.enable=true \ + --label 'traefik.http.routers.nfmailrelay.rule=Host(`nfmailrelay.falahos.my`)' \ + --label traefik.http.routers.nfmailrelay.entrypoints=websecure \ + --label traefik.http.routers.nfmailrelay.tls=true \ + --label traefik.http.routers.nfmailrelay.tls.certresolver=letsencrypt \ + --label traefik.http.services.nfmailrelay.loadbalancer.server.port=8091 \ + nf-mail-relay:latest +``` + +Currently deployed at `https://nfmailrelay.falahos.my` on the Falah OS VPS +(13.140.161.244), covered by the `*.falahos.my` wildcard DNS — no new DNS +record needed. + +## Security notes + +- `RELAY_SECRET` is never committed here — it's set as a Docker service + environment variable on deploy. Rotate with `docker service update + --env-add RELAY_SECRET= nf_mail_relay`. +- The connector directory is bind-mounted **read-only** — this service + cannot modify `falah-ibaas`. +- No auth is used against the Postfix relay itself since it's only bound to + the internal Docker bridge (`172.17.0.1`) — this service is the actual + security boundary between the public internet and that relay. diff --git a/relay.py b/relay.py new file mode 100644 index 0000000..548481b --- /dev/null +++ b/relay.py @@ -0,0 +1,79 @@ +""" +Nur Falah mail relay — tiny HTTP wrapper around the existing, working +falah-ibaas EmailConnector (SMTP via the local Postfix relay Ghost already +uses successfully on this VPS). Exists because Supabase Edge Functions run +on Supabase's cloud, not on this VPS, and can't reach localhost:25 directly +— this gives them a real internet-reachable endpoint that forwards to it. + +Auth: a single shared-secret bearer token (X-Relay-Secret header), generated +locally and never committed to the repo — set as a Supabase project secret +(NF_RELAY_SECRET) and matched here via the RELAY_SECRET env var. +""" +import os +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +import json + +sys.path.insert(0, "/opt/falah-ibaas/connectors/email") +from handler import EmailConnector + +RELAY_SECRET = os.environ["RELAY_SECRET"] +connector = EmailConnector( + smtp_host=os.environ.get("SMTP_HOST", "localhost"), + smtp_port=int(os.environ.get("SMTP_PORT", "25")), + smtp_user=os.environ.get("SMTP_USER", ""), + smtp_pass=os.environ.get("SMTP_PASS", ""), + from_address=os.environ.get("SMTP_FROM", "notifications@falahos.my"), + from_name="Nur Falah", +) + + +class Handler(BaseHTTPRequestHandler): + def _send(self, code, body): + payload = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_GET(self): + if self.path == "/health": + return self._send(200, {"status": "ok", "metrics": connector.metrics()}) + return self._send(404, {"error": "not_found"}) + + def do_POST(self): + if self.path != "/send": + return self._send(404, {"error": "not_found"}) + + if self.headers.get("X-Relay-Secret") != RELAY_SECRET: + return self._send(401, {"error": "unauthorized"}) + + length = int(self.headers.get("Content-Length", 0)) + try: + data = json.loads(self.rfile.read(length)) + except Exception: + return self._send(400, {"error": "invalid_json"}) + + to = data.get("to") + subject = data.get("subject") + if not to or not subject: + return self._send(400, {"error": "to and subject are required"}) + + result = connector.send_email( + to=to, subject=subject, + body_text=data.get("body_text"), body_html=data.get("body_html"), + cc=data.get("cc"), bcc=data.get("bcc"), + ) + code = 200 if result.get("status") == "sent" else 502 + return self._send(code, result) + + def log_message(self, fmt, *args): + sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", "8091")) + server = HTTPServer(("0.0.0.0", port), Handler) + print(f"nf-mail-relay listening on :{port}") + server.serve_forever()