Skip to main content

Email Service — Architecture

Status:email_service FULLY DONE — Phase 3 Soak ✅ 14/14 CLOSED 2026-06-07 · Phase 4 ✅ DEPLOYED & VERIFIED 2026-05-29 15:50 WIB (ACCELERATED)

services/email_service adalah service standalone Go untuk pengiriman email transaksional. Per Phase 4, core_api/internal/email/ DIHAPUS — single path: emailservice.Client → HTTP langsung ke service ini. Mirror obligation EXPIRED.

Service Info

FieldValue
Pathservices/email_service/
Port127.0.0.1:8094 (loopback — hanya dipanggil dari core_api di VM yang sama)
RuntimeGo
AuthX-Internal-API-Key header
SMTP providerGmail smtp.gmail.com:587 · dev: MailPit localhost:1025
VM folder/home/enalfarid/kesles_merchant/merchant_email/
Systemd unitemail-service.service · User=enalfarid · Restart=on-failure

Production Traffic Flow (Phase 4 — single path)

mobile / dashboard user → core_api endpoint
└→ emailServiceClient.SendXxx() (emailservice.Client — Phase 4)
└→ HTTP POST http://127.0.0.1:8094/internal/email/send
└→ email_service handleSend
├→ store.Insert (status=queued)
├→ sender.SendXxx (SMTP via Gmail)
├→ store.MarkSent (status=sent + sent_at)
└→ return 202 {message_id, status:sent}

Tidak ada fallback. Service down = email tidak terkirim. core_api/internal/email/ DIHAPUS Phase 4 2026-05-29.


Endpoints

GET /health

Liveness — no auth. Return 200 selalu selama process up.

{
"service": "email-service",
"status": "ok",
"smtp_configured": true,
"postgres_configured": true,
"app_env": "production"
}

GET /ready

Readiness — no auth. Verifikasi SMTP configured + Postgres ping. Return 503 kalau salah satu fail.

{
"service": "email-service",
"status": "ready",
"checks": { "smtp": "ok", "postgres": "ok" },
"app_env": "production"
}

POST /internal/email/send

Kirim email via template + persist audit row. Auth: X-Internal-API-Key.

Request

{
"template": "otp",
"to_email": "user@example.com",
"payload": { "code": "847291" }
}

Response

HTTPBodyKondisi
202{"status":"sent","template":"...","message_id":"<uuid>"}Sukses
400{"error":"validation_failed"}template / to_email kosong
400{"error":"invalid_payload"}Body bukan JSON valid
401{"error":"unauthorized"}API key salah atau header tidak ada
502{"status":"error","error":"send_failed","message_id":"<uuid>"}SMTP transport error
503{"error":"smtp_not_configured"}Env SMTP belum diisi

message_id selalu ada di response (termasuk saat 502) — bisa dipakai untuk lookup via /internal/email/messages/{id}.


POST /internal/email/log

INSERT audit row tanpa eksekusi SMTP. Phase 1 dual-write: core_api masih kirim record saat fallback ke direct. Auth: X-Internal-API-Key.

{
"template": "otp",
"to_email": "user@example.com",
"subject": "Kode Verifikasi Email Kesles Merchant",
"payload": { "code": "***" },
"status": "direct_sent",
"provider": "direct_core_api"
}

Response 201 {"status":"logged","message_id":"<uuid>"}. status default queued; kalau status=direct_sent, endpoint langsung set sent_at.


GET /internal/email/messages/{id}

Lookup status email by UUID. Auth: X-Internal-API-Key. Return 404 kalau tidak ditemukan, 503 kalau POSTGRES_DSN kosong.

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"template": "otp",
"to_email": "user@example.com",
"subject": "Kode Verifikasi Email Kesles Merchant",
"delivery_status": "sent",
"provider": "smtp_gmail",
"created_at": "2026-05-25T03:30:00Z",
"sent_at": "2026-05-25T03:30:01Z"
}

Templates

6 template tersedia. HTML dirender dari services/email_service/internal/email/templates/*.html (embed.FS — bukan inline di sender.go).

otp

Subject: Kode Verifikasi Email Kesles Merchant

Payload fieldTypeKeterangan
codestring6-digit OTP code

Caller: mobile OTP, dashboard OTP, profile email verify.


password_reset

Subject: Permintaan Reset Password Kesles Merchant

Payload fieldTypeKeterangan
display_namestringNama user — fallback "Pengguna"
reset_urlstringDeep link reset password berisi token — valid 1 jam

device_login_alert

Subject: Notifikasi Login Baru Kesles Merchant

Payload fieldTypeKeterangan
device_namestringNama device — fallback "device baru"
cancel_urlstringURL batalkan sesi baru — valid 10 menit

mobile_user_welcome

Subject: Selamat Datang di Kesles Merchant

Payload fieldTypeKeterangan
display_namestringNama user — fallback "Pengguna"
phonestringNomor HP terdaftar

dashboard_user_welcome

Subject: Selamat Datang di Kesles Merchant Dashboard

Payload fieldTypeKeterangan
display_namestringNama user — fallback "Pengguna"
role_labelstringRole badge ("Admin", "Finance", dst) — opsional
dashboard_urlstringURL login dashboard — fallback https://kesles.com/merchant/dashboard

staff_invitation (baru 2026-05-25)

Subject: Anda Diundang Bergabung di {merchant_name}

Template kontekstual untuk undangan staf — menggantikan template otp generik yang sebelumnya dipakai untuk caller staff_invite. Tone personal, mention merchant + inviter.

Payload fieldTypeKeterangan
merchant_namestringNama merchant yang mengundang
inviter_namestringNama admin yang kirim undangan
rolestringRole yang diassign (mis. "Staff", "Admin")
codestringOTP code undangan
app_urlstringDeep link ke halaman penerimaan undangan
note
Perubahan slug staff_invite

Slug staff_invite di Dispatcher tidak berubah. Yang berubah: method di-rename SendOTPStaffInviteSendStaffInvitation dengan signature payload-based, dan template di payload dari "otp""staff_invitation". Invitee sekarang menerima email berisi nama merchant + inviter + role, bukan "Kode Verifikasi Email" generik.


Caller Integration (core_api — Phase 4)

Phase 4 DONE 2026-05-29. Dispatcher, audit_adapter, internal/email/sender.go sudah DIHAPUS dari core_api. Tidak ada lagi EMAIL_TRANSPORT* atau SMTP_* di core_api.

Call pattern (8 wrapper method)

s.emailServiceClient.SendOTP(ctx, toEmail, code)
s.emailServiceClient.SendPasswordReset(ctx, toEmail, displayName, resetURL)
s.emailServiceClient.SendOTPDashboard(ctx, toEmail, code)
s.emailServiceClient.SendOTPProfile(ctx, toEmail, code)
s.emailServiceClient.SendDeviceLoginAlert(ctx, toEmail, deviceName, cancelURL)
s.emailServiceClient.SendStaffInvitation(ctx, toEmail, merchantName, inviterName, role, code, appURL)
s.emailServiceClient.SendMobileUserWelcome(ctx, toEmail, displayName, phone)
s.emailServiceClient.SendDashboardUserWelcome(ctx, toEmail, displayName, roleLabel, dashboardURL)

Env minimal core_api (Phase 4)

EnvValueKeterangan
EMAIL_SERVICE_BASE_URLhttp://127.0.0.1:8094URL loopback ke email_service
EMAIL_SERVICE_API_KEY(secret)Harus identik dengan INTERNAL_NOTIFICATION_API_KEY di email_service

EMAIL_TRANSPORT*, EMAIL_AUDIT, SMTP_* sudah DIHAPUS dari core_api.


Environment Variables (email_service)

APP_ENV=production # development | staging | production
APP_PORT=8094

INTERNAL_NOTIFICATION_API_KEY= # wajib; harus identik dengan EMAIL_SERVICE_API_KEY di core_api

# Postgres — wajib Phase 1+
POSTGRES_DSN= # kosong = /log + /messages return 503; /send tetap jalan

# SMTP
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_NAME="Kesles Merchant"
SMTP_FROM_EMAIL=

# Retry (Phase 2+)
EMAIL_RETRY_MAX=3
EMAIL_RETRY_INITIAL_BACKOFF_MS=500
EMAIL_RETRY_MAX_BACKOFF_MS=10000
validateConfig() production guard

Di APP_ENV=production, service akan gagal startup kalau INTERNAL_NOTIFICATION_API_KEY, SMTP_USERNAME, atau SMTP_FROM_EMAIL kosong. Guard ini mencegah deploy production tanpa credential lengkap.

POSTGRES_DSN graceful degradation

Kalau POSTGRES_DSN kosong: /internal/email/send tetap jalan dan kirim SMTP tapi tidak persist ke DB. /internal/email/log dan /internal/email/messages/{id} return 503.


Database Schema

Migration: merchant_database/db_kesles_merchant/migrations/v1/029_notification_email_messages.sql

notification.email_messages

Audit log per email send. Pola identik dengan notification.whatsapp_messages.

KolomTypeKeterangan
iduuidPrimary key — caller-generated
templatetextotp / password_reset / device_login_alert / mobile_user_welcome / dashboard_user_welcome / staff_invitation
to_emailtextPenerima
subjecttextSubject email
payloadjsonbInput params (code, displayName, resetURL, dst)
delivery_statustextqueued / sent / failed / retrying
providertextsmtp_gmail (via email_service) / direct_core_api (fallback)
provider_message_idtextKosong untuk SMTP — REST provider isi ini
created_attimestamptz
sent_attimestamptzSet saat MarkSent()
last_errortextSet saat MarkFailed()

Index: (delivery_status, created_at) · (to_email, created_at DESC).

Cara cek distribusi status di prod:

SELECT delivery_status, count(*) FROM notification.email_messages GROUP BY 1;

notification.email_send_attempts

Per-attempt detail untuk retry policy (Phase 2+). 1 email_message bisa punya N attempts.

KolomTypeKeterangan
message_iduuid FKemail_messages.id CASCADE DELETE
attempt_noint1-based
statustextsuccess / error
error_summarytext

Telemetry

Semua send emit slog JSON event email_send:

{
"event": "email_send",
"template": "otp",
"to_masked": "j***@example.com",
"duration_ms": 1240,
"success": true,
"error_class": "ok",
"attempt_no": 1
}

error_class enum: ok / gmail_throttle / gmail_blocked / smtp_auth / smtp_timeout / unknown.

PII protection: email di-mask (john.doe@xj***@x) — tidak pernah masuk journalctl plain.

store.go juga emit slog warn saat DB write gagal:

  • email_insert_failed — INSERT row gagal
  • email_mark_sent_failed — MarkSent gagal
  • email_mark_failed_failed — MarkFailed gagal

Aggregator harian: scripts/email-telemetry-daily.sh.


Phase Soak Metrics (Phase 3) — ✅ 14/14 CLOSED 2026-06-07

Phase 3 soak: 2026-05-25 → 2026-06-07. PASSED 14 hari clean. 14 hari berturut: 100% smtp_gmail, 0 failed, 0 fallback, 0 throttle.

MetricDay 1 (2026-05-25)Day 13 (2026-06-06)Threshold
Latency p503.2s2.8s< 5s ✅
Latency p954.0s3.8s< 5s ✅
Error rate0%0%< 1% ✅
Fallback events000 ✅
Gmail throttle000 ✅

Acceptance Phase 3: 14 hari clean — 0 crash, 0 fallback, error rate < 1%, latency p95 < 5s. ACHIEVED.


Extraction Phases

PhaseStatusDetail
−1 Telemetri✅ DONEslog email_send + classifyEmailErr + maskEmail + aggregator sh live
0 Service Standalone✅ DONE6/6 deliverable — 5 endpoint, 6 template, unit test 608 baris, embed.FS refactor, validateConfig guard
1 Persistence Dual-Write✅ DONEEMAIL_AUDIT=service di prod sejak 2026-05-24 — audit_adapter + /log endpoint
2 Send via Feature Flag✅ DONEEMAIL_TRANSPORT=service global flip 2026-05-25 11:21 — 8/8 caller via email_service
3 Soak Production 14 Hari14/14 CLOSEDMulai 2026-05-25 · Selesai 2026-06-07 · 100% smtp_gmail, 0 failed/fallback/throttle
4 Cleanup core_apiDEPLOYED & VERIFIEDAccelerated 2026-05-29 15:50 · 1.431 LOC dihapus · 8 wrapper method ditambah

Mirror obligation: ✅ EXPIRED PERMANEN (Phase 4 DONE 2026-05-29). core_api/internal/email/ sudah dihapus. Edit template/logic email hanya di services/email_service/.


Dev — Local Setup

# 1. Start MailPit (dev SMTP catcher)
mailpit # Web UI: localhost:8025 · SMTP: localhost:1025

# 2. Set env
export APP_ENV=development
export APP_PORT=8094
export SMTP_HOST=localhost
export SMTP_PORT=1025
export SMTP_FROM_EMAIL=test@kesles.com
export INTERNAL_NOTIFICATION_API_KEY=test-key-dev

# 3. Run
cd services/email_service && go run ./cmd/server

# 4. Test send
curl -X POST http://localhost:8094/internal/email/send \
-H "X-Internal-API-Key: test-key-dev" \
-H "Content-Type: application/json" \
-d '{"template":"otp","to_email":"dev@test.com","payload":{"code":"123456"}}'

# 5. Cek email di MailPit
open http://localhost:8025

Deployment (VM Production)

# Build binary (dari local)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" \
-o services/email_service/dist/email-service \
./services/email_service/cmd/server

# Deploy ke VM
scp -P 2262 services/email_service/dist/email-service \
enalfarid@ptikn3-vm.cluster-vps.dalang.io:/home/enalfarid/kesles_merchant/merchant_email/

ssh -p 2262 enalfarid@ptikn3-vm.cluster-vps.dalang.io \
"systemctl restart email-service"

# Verify
ssh -p 2262 enalfarid@ptikn3-vm.cluster-vps.dalang.io \
"curl -fsS http://127.0.0.1:8094/health"
# expect: {"service":"email-service","status":"ok",...}

# Monitor logs
ssh -p 2262 enalfarid@ptikn3-vm.cluster-vps.dalang.io \
"sudo journalctl -u email-service -f"

  • Service Topology
  • Dashboard Admin API — bulk email path (workaround pre-Phase 0, refactor candidate post Phase 4)
  • Database Schemanotification schema