Preferences: SDD
PRD · Tests · trạng thái: 01-06 đã xây (05-06 từ 14.10.2026, cùng Nhịp thư); trang tuỳ chọn trong tokyo và chọn theo kênh chưa có
Bảng
Hai bảng, hai khoá khác nhau, có chủ ý:
| Bảng | Khoá | Giữ gì |
|---|---|---|
email_preferences (migration 20260907b_email_preferences.sql) | user_id | Huỷ nhận TOÀN BỘ thư gắn kết: unsubscribed_at, source (link = người chọn thôi nhận, suppressed = địa chỉ không nhận được), updated_at. Không có dòng nghĩa là chưa từ chối; cố ý không seed. Migration 20260909c_backfill_suppressed.sql bù source='suppressed' từ các lượt hỏng trước đó. |
email_audience_prefs (migration 20261114a_email_orchestrator.sql) | email_key = lower(trim(email)) | Nhịp (auto/weekly/important/none), nhóm bị tắt (muted, CSV các mã hoc_tap, su_kien, bai_doc, tin_tuan, lam_quen; mã lạ bị bỏ khi đọc), promo_consent_at, câu trả lời thư hỏi lại người hệ cũ, consent_version, source. Khoá theo địa chỉ vì 57 người có hai id ở hai bảng người dùng. Thêm một nhóm mới thì dòng cũ không có nó trong CSV, tức người đã lưu tuỳ chọn mặc định NHẬN nhóm đó. |
Vì sao không gộp: mọi vòng gửi cũ (daily.ts, cổng gửi) đọc huỷ nhận theo user_id, nên "Không nhận thư nào" vẫn ghi email_preferences cho MỌI id của địa chỉ; bảng mới chỉ thêm những lựa chọn mà bảng cũ không chứa được.
Đường đi
| Bước | File | Ghi chú |
|---|---|---|
| Sinh link + header | unsubscribeToken(env, userId) và nhánh gắn kết trong sendEmail() ở api.conan.school/src/shared/email/send.ts | Link huỷ: HMAC của user_id trần với EMAIL_UNSUB_SECRET (giữ nguyên để link trong thư cũ vẫn chạy). Link tuỳ chọn: HMAC của chuỗi có mục đích prefs đứng trước user_id (orchestrator/links.ts), nên chữ ký huỷ không dùng được cho trang tuỳ chọn và ngược lại. Chân thư: "Đổi nhịp nhận thư · Huỷ nhận thư". Thiếu bí mật thì thư gắn kết đi ra không có cả hai link lẫn List-Unsubscribe. |
| Huỷ | GET / POST /api/system/email/unsubscribe?u=...&t=... ở src/domains/system/email-orch.ts | GET chỉ trả trang xác nhận có nút (đổi 14.10.2026: GET từng ghi ngay, và máy quét link huỷ hộ người ta). POST giữ đúng RFC 8058 cho Gmail một chạm: u, t từ query, không cookie, không Origin, trả 200 HTML không chuyển hướng; ghi hỏng thì 503. Ghi source='link'. Log email_unsubscribed. |
| Trang tuỳ chọn | GET / POST /api/system/email/prefs?u=...&t=..., cùng file | GET hiện nhịp, năm nhóm tắt được (CATEGORY_OPTIONS, cùng thứ tự MUTABLE_CATEGORIES: Học tập, Sự kiện và buổi học, Bài đọc và câu chuyện, Tin tuần, Chuỗi thư làm quen; nhóm cuối thêm 14.10.2026 cho journey_day), ô Ưu đãi; đã huỷ thì hiện đúng sự thật là nhịp "Không nhận". POST lưu email_audience_prefs + PREFS_CONSENT_VERSION; none ghi huỷ cho mọi id; nhịp khác gỡ huỷ link (không gỡ suppressed). |
| Hỏi lại người hệ cũ | GET / POST /api/system/email/reconnect?u=...&a=...&t=... (a là yes hoặc no), cùng file | GET là trang xác nhận; POST "Có" là cách DUY NHẤT người hệ cũ được coi là đồng ý nhận thư thường; "Không" huỷ mọi id. |
| Chặn khi gửi | sendEmail() | Thư gắn kết: unsubscribed_at → E_UNSUBSCRIBED; prefsBlock đọc email_audience_prefs theo địa chỉ → E_UNSUBSCRIBED (nhịp none, trả lời "Không") hoặc E_PREFS (nhóm tắt, important, weekly đã có thư trong ~7 ngày). Thi hành cho MỌI vòng gửi thư gắn kết, kể cả daily.ts. |
| Nhớ bị chặn | sendEmail() khi nhận E_RECIPIENT_SUPPRESSED | Ghi source='suppressed'. |
| Lọc ở vòng gửi | src/shared/email/daily.ts; Nhịp thư audience.ts + dispatch.ts | daily.ts loại người có unsubscribed_at ở truy vấn chọn. Nhịp thư lọc ở lúc lập kế hoạch và đọc lại lúc gửi. |
Chưa xây
- Trang tuỳ chọn trong tokyo (
com.conan.schoolkhông gọi tới hai bảng này). - Chọn theo kênh (chỉ có một kênh).
- Tuỳ chọn cho khách không có
user_id(cần cho newsletter): link có chữ ký hiện ký theouser_id.