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.
This commit is contained in:
wmj
2026-08-14 08:00:28 +08:00
commit c0b8981629
3 changed files with 153 additions and 0 deletions
+8
View File
@@ -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"]
+66
View File
@@ -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: <shared 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=<generate with: openssl rand -hex 24> \
--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=<new value> 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.
+79
View File
@@ -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()