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
| Field | Value |
|---|---|
| Path | services/email_service/ |
| Port | 127.0.0.1:8094 (loopback — hanya dipanggil dari core_api di VM yang sama) |
| Runtime | Go |
| Auth | X-Internal-API-Key header |
| SMTP provider | Gmail smtp.gmail.com:587 · dev: MailPit localhost:1025 |
| VM folder | /home/enalfarid/kesles_merchant/merchant_email/ |
| Systemd unit | email-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
| HTTP | Body | Kondisi |
|---|---|---|
| 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 field | Type | Keterangan |
|---|---|---|
code | string | 6-digit OTP code |
Caller: mobile OTP, dashboard OTP, profile email verify.
password_reset
Subject: Permintaan Reset Password Kesles Merchant
| Payload field | Type | Keterangan |
|---|---|---|
display_name | string | Nama user — fallback "Pengguna" |
reset_url | string | Deep link reset password berisi token — valid 1 jam |
device_login_alert
Subject: Notifikasi Login Baru Kesles Merchant
| Payload field | Type | Keterangan |
|---|---|---|
device_name | string | Nama device — fallback "device baru" |
cancel_url | string | URL batalkan sesi baru — valid 10 menit |
mobile_user_welcome
Subject: Selamat Datang di Kesles Merchant
| Payload field | Type | Keterangan |
|---|---|---|
display_name | string | Nama user — fallback "Pengguna" |
phone | string | Nomor HP terdaftar |
dashboard_user_welcome
Subject: Selamat Datang di Kesles Merchant Dashboard
| Payload field | Type | Keterangan |
|---|---|---|
display_name | string | Nama user — fallback "Pengguna" |
role_label | string | Role badge ("Admin", "Finance", dst) — opsional |
dashboard_url | string | URL 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 field | Type | Keterangan |
|---|---|---|
merchant_name | string | Nama merchant yang mengundang |
inviter_name | string | Nama admin yang kirim undangan |
role | string | Role yang diassign (mis. "Staff", "Admin") |
code | string | OTP code undangan |
app_url | string | Deep link ke halaman penerimaan undangan |
staff_inviteSlug staff_invite di Dispatcher tidak berubah. Yang berubah: method di-rename SendOTPStaffInvite → SendStaffInvitation 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)
| Env | Value | Keterangan |
|---|---|---|
EMAIL_SERVICE_BASE_URL | http://127.0.0.1:8094 | URL 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
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.
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.
| Kolom | Type | Keterangan |
|---|---|---|
id | uuid | Primary key — caller-generated |
template | text | otp / password_reset / device_login_alert / mobile_user_welcome / dashboard_user_welcome / staff_invitation |
to_email | text | Penerima |
subject | text | Subject email |
payload | jsonb | Input params (code, displayName, resetURL, dst) |
delivery_status | text | queued / sent / failed / retrying |
provider | text | smtp_gmail (via email_service) / direct_core_api (fallback) |
provider_message_id | text | Kosong untuk SMTP — REST provider isi ini |
created_at | timestamptz | — |
sent_at | timestamptz | Set saat MarkSent() |
last_error | text | Set 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.
| Kolom | Type | Keterangan |
|---|---|---|
message_id | uuid FK | → email_messages.id CASCADE DELETE |
attempt_no | int | 1-based |
status | text | success / error |
error_summary | text | — |
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@x → j***@x) — tidak pernah masuk journalctl plain.
store.go juga emit slog warn saat DB write gagal:
email_insert_failed— INSERT row gagalemail_mark_sent_failed— MarkSent gagalemail_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.
| Metric | Day 1 (2026-05-25) | Day 13 (2026-06-06) | Threshold |
|---|---|---|---|
| Latency p50 | 3.2s | 2.8s | < 5s ✅ |
| Latency p95 | 4.0s | 3.8s | < 5s ✅ |
| Error rate | 0% | 0% | < 1% ✅ |
| Fallback events | 0 | 0 | 0 ✅ |
| Gmail throttle | 0 | 0 | 0 ✅ |
Acceptance Phase 3: 14 hari clean — 0 crash, 0 fallback, error rate < 1%, latency p95 < 5s. ACHIEVED.
Extraction Phases
| Phase | Status | Detail |
|---|---|---|