Platform email self-hosted (Go + React + MariaDB) yang menggabungkan mail server multi-domain ala cPanel dan Sending API ala Amazon SES/Postmark dalam satu binary cvrelay. Lisensi MIT.
Status: v0.8: Fondasi + Sending API + SMTP relay + bounce async, complaint, jeda otomatis + DNS otomatis via Cloudflare + throttle, warmup, relay fallback, pemantauan blocklist + mailbox hosting (terima surat di port 25, baca lewat IMAP, kirim dari Outlook/HP dengan password mailbox, filter spam). Webmail ala Gmail sedang dikerjakan (rencana di Roadmap); POP3, Sieve, dan import IMAP belum ada. Lihat Roadmap.
| Area | Isi |
|---|---|
| Multi-tenant | Tenant, kuota harian, pause, isolasi data per tenant_id di setiap query (dites) |
| API key | cv_live_ / cv_test_, scope, batasan stream dan IP, disimpan sebagai hash |
| Domain | DNS wizard (DKIM RSA-2048 + Ed25519, SPF, return-path, DMARC, MX), verifikasi DNS, rotasi DKIM dengan overlap 48 jam |
| DNS otomatis | Tombol "Pasang via Cloudflare": record dibuat lewat API Cloudflare (token platform atau token per klien, terenkripsi). SPF yang ada digabung, DMARC dan MX milik klien tidak ditimpa. Cek DNS terjadwal tiap 10 menit sampai terverifikasi (maks 7 hari), lalu harian; DNS yang rusak setelah terverifikasi ditandai "DNS bermasalah" dan pengiriman dari domain itu dihentikan sampai diperbaiki (error DNS sementara diabaikan) |
| Sending API | POST /v1/messages, batch 500, template, lampiran, scheduled_at + batal, Idempotency-Key 24 jam, mode test |
| SMTP relay | Port 587 (STARTTLS) dan 465 (TLS implisit), AUTH PLAIN/LOGIN dengan username apikey, header X-CV-Stream/Tags/Metadata, blokir IP setelah 5 login gagal. Pengguna mailbox juga bisa mengirim dari Outlook/HP dengan alamat + password mailbox: From dibatasi ke alamat dan aliasnya, batas 500 penerima/hari (bisa diubah), jeda otomatis bila jauh melewati batas atau banyak hard bounce/complaint, unsubscribe newsletter tidak memblokir email pribadi, sesi yang terbuka putus saat password diganti, login gagal lewat SMTP dan IMAP dihitung bersama |
| Antrean & MTA | Antrean per Recipient di MariaDB (FOR UPDATE SKIP LOCKED + lease), prioritas transactional, MX lookup + fallback A, null MX, TLS oportunistik, DKIM dual signing, VERP return-path |
| Retry & bounce | 1m, 5m, 15m, 30m, 1j, 2j, 4j, lalu tiap 4 jam sampai 5 hari; klasifikasi hard/soft/block/mailbox_full/policy; hard bounce masuk suppression |
| Suppression | Per tenant + global, manual, hard bounce, complaint, one-click unsubscribe (RFC 8058) untuk stream broadcast |
| Bounce async & complaint | Port 25 menerima DSN ke alamat VERP b+<token>@bounce.<domain> (token HMAC, alamat palsu ditolak) dan laporan ARF ke fbl@<platform>; dicocokkan lewat VERP atau header X-CV-Ref |
| Throttle per provider | Batas kirim per menit untuk Gmail, Yahoo, Microsoft, dan lainnya (provider dikenali dari host MX, jadi Google Workspace ikut Gmail); provider dijeda 15 menit bila membalas rate-limit (misal 421 4.7.28). Pesan yang ditahan tidak dihitung percobaan |
| Warmup IP | Kuota harian per provider mengikuti kurva (50, 100, 300, 500, 1rb, ... per hari); broadcast hanya boleh 70% kuota sehingga transactional selalu kebagian; kelebihan ditahan sampai besok |
| Relay fallback | Smart host eksternal (Mailgun/Brevo/SES SMTP) untuk semua atau provider tertentu, AUTH + STARTTLS, password terenkripsi; bisa aktif otomatis saat IP masuk blocklist |
| Pemantauan blocklist | IP dicek tiap jam di Spamhaus ZEN, Barracuda, SpamCop; listing dicatat di audit log. Jawaban 127.255.255.x (query ditolak) dianggap "unknown", bukan listing |
| Jeda otomatis | Tenant dijeda bila hard bounce > 5% (minimal 5) atau complaint > 0,1% (minimal 3) dalam 24 jam, setelah 50 penerima; ambang bisa diatur per tenant, angka sebelum admin mengaktifkan ulang tidak dihitung, dicatat di audit log, alasan tampil di panel |
| Event & webhook | queued, sent, deferred, bounced, rejected, unsubscribed; webhook HMAC-SHA256, retry sampai 24 jam, dead-letter, replay, proteksi SSRF |
| Mailbox hosting | Mailbox per alamat (password argon2id, kuota default 2 GB), alias ke beberapa mailbox, catch-all *@domain, sub-address nama+tag@. Port 25 menerima surat untuk domain hosted yang terverifikasi (bukan open relay), menambah Return-Path, Received, Authentication-Results (DKIM) dan membuang header palsu atas nama server ini; header > 256 KB ditolak. Kuota dicek saat RCPT (552) |
| Filter spam | Surat masuk dinilai: SPF (evaluator sendiri, RFC 7208), DKIM, DMARC (relaxed/strict alignment, sp=, pct=), DNSBL (Spamhaus ZEN, Barracuda, SpamCop), pola header (From ganda/rusak, nama tampilan menyamar). Skor ≥ 6 masuk Junk, ≥ 15 (misal pemalsuan domain berkebijakan p=reject) ditolak 550; DNS gagal sementara pada domain p=reject dijawab 451. Header Authentication-Results (spf/dkim/dmarc) dan X-Spam-Status per mailbox. rspamd opsional (CVRELAY_RSPAMD_URL). Daftar izin/blokir pengirim per tenant atau per mailbox (izin hanya berlaku bila domain pengirim terbukti; blokir juga mencocokkan envelope). Junk dan Trash dikosongkan otomatis 30 hari setelah pesan masuk ke sana |
| IMAP | Port 993 (TLS) dan 143 (STARTTLS), login tanpa TLS ditolak. IMAP4rev1 + IDLE, UIDPLUS, MOVE, SPECIAL-USE, NAMESPACE, APPENDLIMIT. Folder bawaan INBOX/Sent/Drafts/Trash/Junk/Archive. Beberapa perangkat sekaligus tetap sinkron (EXISTS/EXPUNGE/FLAGS). Login gagal 10x per 15 menit per IP (IPv6 per /64) diblokir; ganti password atau nonaktifkan mailbox memutus sesi yang terbuka |
| Panel | React console (embed di binary) untuk tim Codeverta: tenant, domain & DNS, mailbox & alias, aktivitas + timeline, template + preview, API key, webhook, suppression |
cp .env.example .env
docker compose run --rm cvrelay ctl genkey # isi ke CVRELAY_MASTER_KEY
# isi juga CVRELAY_ADMIN_TOKEN, password MariaDB, domain platform
docker compose up -d
open http://localhost:8080 # login dengan CVRELAY_ADMIN_TOKENMembuat tenant dan API key lewat CLI:
docker compose exec cvrelay cvrelay ctl tenant create "Toko ABC"
docker compose exec cvrelay cvrelay ctl apikey create tnt_01J... --scopes=send,read_logs- Tambah domain dengan tipe
hostedatauboth, pasang record DNS (termasukMX 10 <CVRELAY_HOSTNAME>), lalu verifikasi. - Buat mailbox di panel (menu Mailbox) atau lewat API:
curl -X POST https://relay.codeverta.com/v1/mailboxes -H "Authorization: Bearer $KEY" \
-d '{"address":"contact@client1.com","name":"Kontak","password":"rahasia-panjang","quota_mb":2048}'- Di Outlook / iPhone / Android, tambah akun IMAP:
| Server masuk | CVRELAY_HOSTNAME, port 993 (SSL/TLS) atau 143 (STARTTLS) |
| Username | alamat lengkap, misal contact@client1.com |
| Password | password mailbox |
| Server keluar | CVRELAY_HOSTNAME, port 587 (STARTTLS) atau 465 (SSL/TLS), dengan autentikasi username + password yang sama |
Sertifikat TLS (CVRELAY_TLS_CERT / CVRELAY_TLS_KEY) wajib untuk IMAP dan SMTP. Salinan di Sent disimpan oleh Outlook/HP sendiri lewat IMAP.
Menerima cukup dengan MX + DKIM. Untuk mengirim dari domain bertipe hosted, pasang juga SPF dan return-path (bounce.<domain>) lalu jalankan Cek DNS; sampai itu ada, Outlook mendapat penolakan yang menjelaskan record yang kurang. Batas kirim harian dihitung per hari UTC (reset pukul 07.00 WIB), sama seperti kuota tenant. Bounce dari email pribadi dikirim sebagai pemberitahuan "Tidak terkirim" ke INBOX pengirim, dan alamat yang pernah hard bounce ditolak langsung saat mengirim.
curl https://relay.codeverta.com/v1/messages \
-H "Authorization: Bearer cv_live_..." \
-H "Idempotency-Key: order-12345-receipt" \
-H "Content-Type: application/json" \
-d '{
"from": "Toko ABC <no-reply@tokoabc.id>",
"to": ["budi@example.com"],
"subject": "Invoice #12345",
"html": "<p>Halo Budi, total Rp 250.000</p>",
"tags": ["invoice"],
"metadata": {"order_id": "12345"}
}'
# 202 {"id":"msg_01J...","status":"queued"}Spesifikasi lengkap: api/openapi.yaml.
Daftarkan IP publik server di panel (Deliverability → IP pengirim) supaya warmup dan pemantauan blocklist berjalan. Catatan: Spamhaus menolak query dari resolver publik (Google/Cloudflare DNS); pakai resolver sendiri (misal unbound di server) atau Spamhaus DQS, kalau tidak hasilnya akan "unknown".
Set CVRELAY_CLOUDFLARE_TOKEN dengan token API (izin Zone:Read dan DNS:Edit untuk zone klien) bila zone klien ada di akun Cloudflare Codeverta. Klien yang zone-nya di akun Cloudflare sendiri bisa diberi token per klien dari panel (halaman Domain & DNS); token itu diverifikasi ke Cloudflare lalu disimpan terenkripsi dan dipakai menggantikan token platform.
Token platform hanya bisa dipakai super admin dari panel, karena token itu menjangkau zone banyak klien (ADR 0005). API key klien hanya bisa memakai token Cloudflare milik klien itu sendiri. Zone platform tidak pernah diubah, dan zone yang sudah dipakai domain terverifikasi milik klien lain ditolak.
Aturan pemasangan: DKIM, return-path (CNAME, tanpa proxy) dan MX dibuat bila belum ada; SPF yang sudah ada digabung dengan include:_spf.<platform> sebelum mekanisme all (tidak peka huruf besar, menghormati modifier), tetapi dibatalkan sebagai conflict bila total DNS lookup (dihitung sampai include bertingkat) akan melebihi 10 atau SPF memakai redirect=; DMARC yang sudah ada dibiarkan; MX yang menunjuk ke penyedia lain (misal Google) dilaporkan sebagai conflict dan tidak diubah. Pakai jenis domain "Hanya kirim" bila mailbox klien tetap di penyedia lama.
Return-path setiap pesan adalah b+<token>@bounce.<domain-klien>, dan domain klien memasang CNAME bounce.<domain-klien> ke bounce.<platform>. Supaya DSN sampai ke server ini, pasang sekali di zona platform:
bounce.relay.codeverta.com. MX 10 mail.relay.codeverta.com.
bounce.relay.codeverta.com. TXT "v=spf1 ip4:<IP-server> -all"
_spf.relay.codeverta.com. TXT "v=spf1 ip4:<IP-server> -all"
Lalu daftarkan fbl@relay.codeverta.com ke feedback loop ISP (Yahoo CFL, Microsoft JMRP/SNDS). Port 25 masuk harus terbuka.
Laporan complaint hanya dipercaya bila bertanda tangan DKIM valid dari domain ISP di allowlist (default: Yahoo, AOL, Outlook/Hotmail, Microsoft, Comcast, Fastmail, Zoho, Mail.ru, Yandex), karena token di header pesan bisa dilihat dan disalin setiap penerima. Alamat VERP hanya diterima di bounce.<domain pengirim pesan>.
Aplikasi yang sudah memakai SMTP cukup ganti host dan kredensial. Password adalah API key dengan scope send. Alamat From di header harus dari domain terverifikasi milik tenant (bukan envelope sender).
# Laravel .env
MAIL_MAILER=smtp
MAIL_HOST=mail.relay.codeverta.com
MAIL_PORT=587
MAIL_ENCRYPTION=tls
MAIL_USERNAME=apikey
MAIL_PASSWORD=cv_live_...
MAIL_FROM_ADDRESS=no-reply@tokoabc.id// Nodemailer
nodemailer.createTransport({ host: "mail.relay.codeverta.com", port: 587, auth: { user: "apikey", pass: process.env.CV_KEY } });Header opsional (dibaca lalu dibuang sebelum dikirim): X-CV-Stream: broadcast, X-CV-Tags: invoice, bulanan, X-CV-Metadata: {"order_id":"12345"}. Penerima di envelope yang tidak ada di To/Cc dicatat sebagai bcc, dan header Bcc dibuang. Relay hanya berjalan jika CVRELAY_TLS_CERT/CVRELAY_TLS_KEY di-set (AUTH tanpa TLS ditolak).
Setiap POST membawa X-CV-Timestamp (unix) dan X-CV-Signature: v1=<hex>, di mana hex = HMAC-SHA256(secret, "<timestamp>.<body>"). Tolak jika timestamp lebih dari 5 menit.
import crypto from "node:crypto";
const expected = "v1=" + crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));Kebutuhan: Go 1.24+, Node 22+, MariaDB 10.6+ (11.4 disarankan).
# MariaDB lokal untuk tes integrasi: user butuh hak CREATE/DROP DATABASE
mysql -uroot -e "CREATE USER 'cvrelay'@'%' IDENTIFIED BY 'cvrelay'; GRANT ALL ON *.* TO 'cvrelay'@'%';"
make test # go test -race ./... dengan MariaDB sungguhan
make build # build React lalu binary bin/cvrelay
cd web && npm run dev # UI dev server, /v1 diproksi ke :8080Tes integrasi membuat database sekali pakai per tes (internal/testkit); tanpa CVRELAY_TEST_DSN tes tersebut dilewati.
Dibangun dengan skill mattpocock/skills:
- domain-modeling: glosarium di
CONTEXT.mddan keputusan didocs/adr/(6 ADR). Pakai istilah di glosarium (Tenant, Recipient, Suppression, ...) di kode, tes, dan diskusi. - tdd: tes hanya di empat seam yang disepakati, lewat interface publik, bukan internals:
- REST API v1 (
internal/api/*_test.go): httptest ke router asli + MariaDB asli. - Delivery worker (
internal/delivery/delivery_test.go): worker mengirim ke MX palsu (go-smtpin-process), DKIM diverifikasi independen. - Modul murni:
retry,bounce,webhooksigner,dkim,mime,secret. - Webhook dispatcher (
internal/webhook/dispatcher_test.go): penerima httptest memverifikasi signature. - SMTP relay (
internal/submission/submission_test.go): kliennet/smtpsungguhan (STARTTLS + AUTH) ke listener in-process; hasil dicek lewat REST API dan MX palsu. - Inbound bounce/FBL (
internal/inbound/inbound_test.go): MTA lain mengirim DSN/ARF ke listener port 25; hasil dicek lewat REST API (status, event, suppression, status tenant). Parser DSN/ARF juga punya fuzz test. - Mailbox REST (
internal/api/mailboxes_test.go): CRUD mailbox/alias, validasi, isolasi tenant. - Port 25 ke IMAP (
internal/imapd/*_test.go): kliennet/smtpmengirim ke listener port 25, lalu klien IMAP sungguhan (go-imap/v2/imapclient, TLS) membaca: routing alias/catch-all, kuota, header jejak, flag, MOVE/COPY, dua perangkat + IDLE, keamanan login.
- REST API v1 (
cmd/cvrelay/ binary: serve, migrate, ctl
internal/
app/ composition root (merakit semua layanan)
api/ REST API v1 + tes seam API
sending/ menerima permintaan kirim (REST + raw SMTP), log pengiriman
submission/ SMTP relay 587/465
inbound/ port 25: surat masuk mailbox, DSN bounce async, laporan FBL
mailbox/ mailbox, alias, folder, pesan, kuota, penyapu blob
imapd/ server IMAP 993/143
reputation/ bounce/complaint rate 24 jam dan jeda otomatis
delivery/ MTA keluar: klaim antrean, DKIM, SMTP, retry, bounce, unsubscribe
webhook/ signer, CRUD webhook, dispatcher
domain/ tenant/ template/ suppression/ events/
dkim/ mime/ bounce/ retry/ secret/ blob/ dnsx/ store/ id/
testkit/ DB sekali pakai, DNS palsu, MX palsu, jam palsu
migrations/ skema MariaDB (di-embed)
web/ React console (Vite + TanStack Query + Tailwind)
api/openapi.yaml
docs/adr/
- MariaDB, bukan PostgreSQL (ADR 0001): tanpa RLS, jadi isolasi tenant ditegakkan di repository dan dites.
- Satu SPA untuk admin + tenant console (ADR 0004).
- Router
chiditulis tangan, belum di-generate dari OpenAPI; migrasi memakai runner kecil sendiri (goose terbaru butuh Go 1.26). - Event
deliveredbelum dikirim terpisah; untuk SMTP keluarsentsudah berarti diterima MX tujuan.
Irisan berikut, kira-kira urut prioritas:
- Webmail ala Gmail, berurutan: webmail baca (sesi, HTML aman, thread, pencarian FULLTEXT, label), webmail tulis (editor, lampiran, draf, tanda tangan, balasan otomatis, branding, ID/EN), lalu admin klien (kelola mailbox/alias/kuota dalam batas paket, audit log). Rilis 2: aturan filter, kontak, kirim terjadwal, undo send, snooze, 2FA, proxy gambar, Web Push.
- Mail server lanjutan: import IMAP, POP3, ManageSieve, forward ke alamat luar (SRS), batas login per akun yang tersimpan di DB, modseq per pesan.
- Observability:
/metricsPrometheus, OpenTelemetry, ACME/TLS bawaan. - Multi-IP / IP pool per stream (struktur data sudah ada), MTA-STS/DANE saat mengirim.