Lewati ke konten utama

Email Service โ€” VM Deploy Runbook

Status: ๐ŸŸข Active (mirror pola firebase + whatsapp) Owner: Backend Lead Latest: 2026-05-29 โ€” Phase 4 DEPLOYED & VERIFIED 2026-05-29 15:50 WIB (ACCELERATED). core_api/internal/email/ DIHAPUS. Single path: emailservice.Client โ†’ HTTP. Phase 3 soak background Day 5/14 PASS ongoing.

Dokumen terkait:


1. VM topologyโ€‹

ItemValue
Hostptikn3-vm.cluster-vps.dalang.io
User / Groupenalfarid / enalfarid
Folder root/home/enalfarid/kesles_merchant/merchant_email/
Binary namaemail-service (Linux ELF amd64 stripped, ~8.9 MB)
Env file.env.production (chmod 644, gitignored)
Systemd unit/etc/systemd/system/email-service.service
Listen port127.0.0.1:8094 (loopback internal, dipakai core_api)
Logssudo journalctl -u email-service [-f] [--since="1 hour ago"]
OutboundSMTP submission port 587 ke smtp.gmail.com + Postgres ke DB host internal

Pattern folder + systemd unit identik dengan merchant_firebase/ + merchant_whatsapp/ supaya operator tidak belajar layout baru per service.


2. Initial deploy (first-time setup)โ€‹

2.1 Build binary di laptopโ€‹

cd ~/Macbook\ pro/development/kesles_merchant/services/email_service

# Cross-compile Linux amd64 stripped
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build \
-trimpath \
-ldflags="-s -w" \
-o dist/email-service \
./cmd/server

# Verifikasi ukuran (~8-12 MB)
ls -lh dist/email-service
file dist/email-service # โ†’ ELF 64-bit LSB executable, x86-64, statically linked

Atau pakai helper script:

./scripts/build_email_service.sh linux-prod # โ†’ dist/email-service Linux ELF amd64 stripped

2.2 Create folder + env di VMโ€‹

ssh enalfarid@ptikn3-vm.cluster-vps.dalang.io
mkdir -p ~/kesles_merchant/merchant_email/
cd ~/kesles_merchant/merchant_email/

# Copy template lalu isi credential
cp ~/kesles_merchant/services/email_service/.env.example .env.production
chmod 644 .env.production
nano .env.production

Env wajib di production (kalau salah satu kosong, service refuse start via validateConfig post G3 2026-05-25):

EnvSumber
APP_ENV=productionstatic
APP_PORT=8094static (loopback)
INTERNAL_NOTIFICATION_API_KEYrotate sync dengan caller EMAIL_SERVICE_API_KEY di core_api
POSTGRES_DSNpostgres://kesles:...@<DB_HOST>:5432/db_kesles_merchant?sslmode=disable
SMTP_HOST=smtp.gmail.comstatic (atau provider lain post-migration)
SMTP_PORT=587static (submission port, STARTTLS)
SMTP_USERNAMEGmail address sender
SMTP_PASSWORDGmail App Password (16-char, no spaces) โ€” bukan password account
SMTP_FROM_NAME=Kesles Merchantstatic
SMTP_FROM_EMAILdisplay "From" header (biasanya sama dengan SMTP_USERNAME)
EMAIL_RETRY_MAXoptional, default 3
EMAIL_RETRY_INITIAL_BACKOFF_MSoptional, default 500
EMAIL_RETRY_MAX_BACKOFF_MSoptional, default 10000

Generate Gmail App Password: Google Account โ†’ Security โ†’ 2FA โ†’ App passwords โ†’ Generate. 16-char output (mis. abcd efgh ijkl mnop) โ€” paste tanpa spasi ke SMTP_PASSWORD.

2.3 SCP binaryโ€‹

# Dari laptop
scp dist/email-service enalfarid@ptikn3-vm.cluster-vps.dalang.io:~/kesles_merchant/merchant_email/

# Verifikasi di VM
ssh enalfarid@ptikn3-vm.cluster-vps.dalang.io 'ls -lh ~/kesles_merchant/merchant_email/email-service'

2.4 Install systemd unitโ€‹

Buat /etc/systemd/system/email-service.service:

[Unit]
Description=Kesles Merchant - Email Service
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=enalfarid
Group=enalfarid
WorkingDirectory=/home/enalfarid/kesles_merchant/merchant_email
EnvironmentFile=/home/enalfarid/kesles_merchant/merchant_email/.env.production
ExecStart=/home/enalfarid/kesles_merchant/merchant_email/email-service
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=email-service

# Hardening (selaras firebase + whatsapp)
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=true
ReadWritePaths=/home/enalfarid/kesles_merchant/merchant_email

[Install]
WantedBy=multi-user.target

Enable + start:

sudo systemctl daemon-reload
sudo systemctl enable email-service
sudo systemctl start email-service
sudo systemctl status email-service # Expected: active (running)

2.5 Smoke test post-deployโ€‹

# 1. Liveness
curl -s http://127.0.0.1:8094/health | jq
# Expected: {"service":"email-service","status":"ok","smtp_configured":true,"postgres_configured":true,"app_env":"production"}

# 2. Readiness (DB ping + SMTP config check)
curl -s http://127.0.0.1:8094/ready | jq
# Expected: {"service":"email-service","status":"ready","checks":{"smtp":"ok","postgres":"ok"},"app_env":"production"}

# 3. Internal auth wall (tanpa header โ†’ 401)
curl -s -X POST http://127.0.0.1:8094/internal/email/send \
-H "Content-Type: application/json" \
-d '{"template":"otp","to_email":"test@example.com","payload":{"code":"000000"}}'
# Expected: {"error":"unauthorized"}

# 4. Internal endpoint dengan header (test ke email kamu sendiri, JANGAN customer)
KEY=$(grep "^INTERNAL_NOTIFICATION_API_KEY=" .env.production | cut -d= -f2)
curl -s -X POST http://127.0.0.1:8094/internal/email/send \
-H "Content-Type: application/json" \
-H "X-Internal-API-Key: $KEY" \
-d '{"template":"otp","to_email":"<your-email@kesles.com>","payload":{"code":"123456"}}'
# Expected: {"status":"sent","template":"otp","message_id":"<uuid>"}

# 5. Lookup audit row
curl -s http://127.0.0.1:8094/internal/email/messages/<uuid> \
-H "X-Internal-API-Key: $KEY" | jq

3. Re-deploy (update binary)โ€‹

# 1. Build di laptop
cd ~/Macbook\ pro/development/kesles_merchant/services/email_service
./scripts/build_email_service.sh linux-prod

# 2. SCP (overwrite binary)
scp dist/email-service enalfarid@ptikn3-vm.cluster-vps.dalang.io:~/kesles_merchant/merchant_email/

# 3. Restart service (zero-downtime kurang dari 2 detik)
ssh enalfarid@ptikn3-vm.cluster-vps.dalang.io 'sudo systemctl restart email-service'

# 4. Verify (jangan skip โ€” kalau startup gagal validateConfig, systemd restart loop)
ssh enalfarid@ptikn3-vm.cluster-vps.dalang.io \
'sudo systemctl status email-service && curl -s http://127.0.0.1:8094/ready | jq'

Selama restart, ada window ~2 detik service tidak respond. Phase 4 DONE (2026-05-29): tidak ada lagi fallback โ€” restart window jadi hard-block. Email yang fire saat window itu akan error. Pertimbangkan blue-green deploy (2 instance + LB) kalau traffic >100/min.


4. Env rotationโ€‹

4.1 Rotate INTERNAL_NOTIFICATION_API_KEY (paired dengan core_api)โ€‹

Service ini + caller core_api harus rotate sekaligus supaya tidak ada window auth mismatch.

# 1. Generate new key (32 char minimum)
NEW_KEY=$(openssl rand -base64 48 | tr -d '/+=' | head -c 48)

# 2. Di VM email: update .env.production
ssh enalfarid@ptikn3-vm.cluster-vps.dalang.io
nano ~/kesles_merchant/merchant_email/.env.production
# โ†’ INTERNAL_NOTIFICATION_API_KEY=<NEW_KEY>

# 3. Di VM core_api: update .env.production
nano ~/kesles_merchant/merchant_core_api/.env.production
# โ†’ EMAIL_SERVICE_API_KEY=<NEW_KEY> (same value)

# 4. Restart kedua service hampir bersamaan
sudo systemctl restart email-service && sudo systemctl restart merchant-core-api

# 5. Verify dengan trigger email dari dashboard (mis. password reset)

Window mismatch ~5 detik antara dua restart. Phase 4 DONE (2026-05-29): tidak ada lagi fallback ke direct SMTP. Email yang fire saat window mismatch akan error (auth fail). Lakukan restart hampir bersamaan untuk minimasi window.

4.2 Rotate SMTP_PASSWORD (Gmail App Password 90-hari)โ€‹

Gmail App Password punya 90-hari rotation policy. Procedure:

# 1. Generate App Password baru di Google Account
# Security โ†’ 2FA โ†’ App passwords โ†’ Generate
# Copy 16-char output (mis. "abcd efgh ijkl mnop")

# 2. Update .env.production di VM (paste tanpa spasi)
ssh enalfarid@ptikn3-vm.cluster-vps.dalang.io
nano ~/kesles_merchant/merchant_email/.env.production
# โ†’ SMTP_PASSWORD=abcdefghijklmnop

# 3. Restart
sudo systemctl restart email-service

# 4. Smoke test send OTP ke email kamu
KEY=$(grep "^INTERNAL_NOTIFICATION_API_KEY=" .env.production | cut -d= -f2)
curl -s -X POST http://127.0.0.1:8094/internal/email/send \
-H "X-Internal-API-Key: $KEY" \
-d '{"template":"otp","to_email":"<your-email>","payload":{"code":"999999"}}'

# 5. Revoke App Password lama di Google Account UI (jangan biarkan menggantung)

Kalau App Password expire tanpa rotation, slog event email_send akan emit error_class=smtp_auth (post G4 regex ordering fix). Alert threshold di ยง5 Monitoring trigger immediate.

4.3 Rotate ke provider transactional (future)โ€‹

Email_service sudah punya Provider interface di internal/email/provider.go. Migration ke SendGrid / AWS SES / Postmark = tambah provider implementation + swap di NewSender. Detail di extraction-plan.md roadmap.


5. Monitoring & alertingโ€‹

5.1 Log streamโ€‹

# Real-time tail
sudo journalctl -u email-service -f

# Hari ini
sudo journalctl -u email-service --since today

# Filter structured event (slog JSON)
sudo journalctl -u email-service --since "1 hour ago" -o cat | jq 'select(.event == "email_send")'

# Filter error class tertentu
sudo journalctl -u email-service --since today -o cat | \
jq 'select(.event == "email_send" and .error_class == "smtp_auth")'

# Persistence failure events (Phase 3 Day 1 fix 2026-05-25 11:52)
sudo journalctl -u email-service --since today -o cat | \
jq 'select(.event | startswith("email_") and endswith("_failed"))'

5.2 Event schema referenceโ€‹

Lihat architecture.md ยง5 Observability untuk schema lengkap email_send event + 6-class taxonomy + persistence failure events (email_insert_failed, email_mark_sent_failed, email_mark_failed_failed).

5.3 Phase 3 soak threshold (2026-05-25 โ†’ 2026-06-08)โ€‹

MetricThresholdDay 1 actualAction kalau breach
Volume / 24h> 10013normal โ€” Day 1 traffic
p50 latency< 3s target3.2stune SMTP connection pool
p95 latency< 5s4.0sinvestigate slow recipient domain
Max latency< 10s4.0scheck SMTP timeout config
Error rate< 2%0%investigate per error_class
Fallback events== 00check service availability
Gmail throttle (error_class=gmail_throttle)== 00reduce volume atau migrate provider
Service uptime== 100%100%investigate systemd restart count

5.4 Alert threshold per error classโ€‹

Error classThresholdAction
smtp_auth count > 0 / 5 minRotate SMTP_PASSWORD (Gmail App Password expire / leak)
gmail_throttle count > 5 / hourReduce volume atau accelerate provider migration
gmail_blocked count > 0 / 5 minInvestigate content (spam policy) atau sender reputation
smtp_timeout rate > 5% / 10 minCek network ke smtp.gmail.com:587
smtp_config count > 0Service mis-configured (cek .env.production)
email_*_failed count > 0 / 5 minDB unreachable / schema drift
/ready 503 > 1 minDB unreachable atau SMTP config rusak

5.5 Daily telemetry aggregatorโ€‹

# Run di laptop atau VM
./scripts/email-telemetry-daily.sh --since "1 day ago"
# Output: volume per template, latency p50/p95, error_class distribution

Sumber decision deterministik untuk Phase 3 soak GO/NO-GO 2026-06-08.


6. Rollback procedureโ€‹

Kalau redeploy bermasalah:

6.1 Rollback via systemdโ€‹

# 1. SSH ke VM, lihat last working binary kalau ada backup
ls -la ~/kesles_merchant/merchant_email/

# Default: tidak ada backup (manual practice belum baku, TODO improvement)

6.2 Rollback via git checkout + rebuildโ€‹

# Di laptop
cd ~/Macbook\ pro/development/kesles_merchant/services/email_service
git log --oneline -5 # cari commit hash stable
git checkout <commit-hash> -- . # checkout source
./scripts/build_email_service.sh linux-prod
scp dist/email-service enalfarid@ptikn3-vm.cluster-vps.dalang.io:~/kesles_merchant/merchant_email/

# Di VM
ssh enalfarid@ptikn3-vm.cluster-vps.dalang.io 'sudo systemctl restart email-service'

# Restore source di laptop
git checkout main -- .

6.3 Rollback Phase 4 (emergency)โ€‹

Phase 2 cutover rollback TIDAK APPLICABLE โ€” Phase 4 DONE 2026-05-29. core_api/internal/email/ sudah dihapus. Tidak ada lagi fallback ke direct SMTP.

Rollback saat ini hanya via:

# 1. Git revert commit Phase 4 di local repo
git revert <phase4-commit-hash>

# 2. Rebuild binary core_api
./scripts/build_core_api.sh linux-prod

# 3. Upload + restart core_api via FTP workflow

Kalau service email-service mengalami bug critical, rollback binary email-service ke versi sebelumnya (lihat ยง6.2). Email tidak terkirim selama service down.

6.4 Yang TIDAK perlu di-rollbackโ€‹

  • Audit row di notification.email_messages โ€” append-only, tidak ada UPDATE/DELETE pattern
  • HTML template di templates/*.html (post #2 embed.FS) โ€” embedded di binary, kalau salah render = redeploy binary lama

7. Troubleshootingโ€‹

Service refuse start di productionโ€‹

sudo systemctl status email-service
sudo journalctl -u email-service --since "5 minutes ago"

Pesan-pesan common (post G3 validateConfig 2026-05-25):

LogPenyebabFix
INTERNAL_NOTIFICATION_API_KEY is required in productionenv kosong di .env.productionedit env, restart
SMTP_FROM_EMAIL is required in productionenv kosongedit env, restart
POSTGRES_DSN is required in productionenv kosongedit env, restart
postgres init failed: ... no such hostPOSTGRES_DSN salah host atau DB VM downcek DB topology
bind: address already in useport 8094 dipakai process lainsudo ss -lntp | grep 8094, kill atau ganti port

Endpoint /internal/* selalu 503 internal_key_not_configuredโ€‹

INTERNAL_NOTIFICATION_API_KEY belum di-load. Kalau service running tapi balas 503, kemungkinan .env.production typo atau systemd unit lupa EnvironmentFile= line.

# Cek env actual yang di-load systemd
sudo systemctl show email-service | grep -i environment

SMTP auth fail (error_class=smtp_auth)โ€‹

sudo journalctl -u email-service --since "10 minutes ago" -o cat | \
jq 'select(.event == "email_send" and .error_class == "smtp_auth")'

Common causes:

  • Gmail App Password expire (90-hari rotation) โ€” generate baru + update .env.production (lihat ยง4.2)
  • Gmail account 2FA di-disable accidentally โ€” re-enable
  • App Password di-revoke (Google Security alert)

Gmail throttle (error_class=gmail_throttle)โ€‹

Gmail SMTP limit: 500 email/hari per sending account (non-Workspace) atau 2000/hari (Workspace). Symptom: error 421 4.7.0 Try again later muncul.

Fix immediate: reduce traffic (drain queue gradually). Fix long-term: migrate ke provider transactional (SendGrid / AWS SES / Postmark โ€” lihat roadmap extraction-plan.md).

Phase 3 Day 1 fix: silent stuck queued rowโ€‹

Sebelum 2026-05-25 11:52, kalau store.MarkSent() / MarkFailed() / Insert() gagal (DB hiccup), email kirim sukses tapi DB row stuck di status queued selamanya tanpa visibility.

Post-fix: service emit slog warn eksplisit (email_mark_sent_failed, email_mark_failed_failed, email_insert_failed) dengan message_id + error. Operator bisa filter:

sudo journalctl -u email-service --since "1 hour ago" -o cat | \
jq 'select(.event == "email_mark_sent_failed")'

Manual fix row stuck: UPDATE notification.email_messages SET delivery_status='sent' WHERE id='<uuid>' AND delivery_status='queued' setelah konfirmasi email actual terkirim.


8. Disaster recoveryโ€‹

Service stateless โ€” semua state di Postgres. Rebuild scenario:

  1. Re-provision VM (atau VM baru)
  2. Setup user enalfarid + folder ~/kesles_merchant/merchant_email/
  3. SCP binary + .env.production (dari secret vault / 1Password)
  4. Install systemd unit dari ยง2.4
  5. systemctl enable && start
  6. Test smoke send dari ยง2.5
  7. Update core_api EMAIL_SERVICE_BASE_URL kalau VM pindah host

RTO target: <30 menit (kalau credential + binary tersedia siap). RPO: 0 (Postgres replicated, notification.email_messages row tidak hilang).

Catatan Phase 4 (DONE 2026-05-29): tidak ada lagi fallback ke direct SMTP. VM downtime = email send error langsung ke caller. Mitigasi utama: Restart=on-failure systemd + graceful shutdown 15s.


9. Dev SMTP (MailPit)โ€‹

Development lokal pakai MailPit (capture all outgoing email, no real send). Install via brew install mailpit. Verified 2026-05-21:

mailpit & # start background
curl -sS http://localhost:8025/api/v1/messages # API
open http://localhost:8025 # web UI

# Env lokal
APP_ENV=development
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USERNAME= # kosong = no auth
SMTP_PASSWORD= # kosong
SMTP_FROM_EMAIL=noreply@dev.kesles.local

validateConfig() no-op di APP_ENV=development jadi env minimal cukup (tidak butuh real Gmail App Password). Bisa edit .env.development di folder service, service auto-load (lihat internal/app/config.go::loadDotenv).


10. Dokumen referensiโ€‹

Memory references:

  • project_email_service_vm_deployment โ€” VM path convention
  • feedback_email_service_mirror โ€” Mirror obligation EXPIRED Phase 4 done 2026-05-29
  • feedback_credential_handling โ€” Operator credential rotation note