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:
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user