ADR-0001. Unsubscribe theo token (không auth, không session)
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-05-01 |
| Deciders | outreach-team |
| Supersedes | — |
Bối cảnh
- Người nhận newsletter phải có thể unsubscribe từ một link một-click trong email, mà không cần đăng nhập.
- Một subscriber là một actor công khai, không xác thực — không có JWT, không có session, không có merchant scope.
- Chúng tôi cần một cách để cấp quyền "deactivate đúng subscription này" an toàn để nhúng vào một URL email plaintext và không thể dùng để enumerate hay deactivate subscription của người khác.
Quyết định
Mỗi dòng Subscriber mang một unsubscribeToken, sinh qua IdGenerator.nextId() (một Snowflake 64-bit) khi insert. Link unsubscribe là GET /subscribers/unsubscribe?token=<unsubscribeToken> và yêu cầu không xác thực.
SubscriberService.unsubscribe() tra cứu token; trúng thì set status=DEACTIVATED và đóng dấu unsubscribedAt; trượt thì throw UNSUBSCRIBE_INVALID_TOKEN (HTTP 404). Token được khai báo trong hiddenProperties của model, nên không bao giờ được trả về bởi bất kỳ endpoint đọc nào — cách duy nhất để có nó là nhận email.
Hệ quả
| Ưu | Nhược |
|---|---|
| Unsubscribe một-click với zero auth friction | Token là một bearer secret trong URL email plaintext |
| Token không bao giờ rò qua API (hidden property) | Không expiry / rotation — một link rò hoạt động vô thời hạn |
| Không gian Snowflake (64-bit) làm việc đoán bất khả thi | Re-subscribe không phát token mới (token ổn định qua deactivate/reactivate) |
| Idempotent: hit lại link trên một dòng đã-deactivate là vô hại | 404 trên token sai hơi rò "token không tồn tại" |
Các phương án đã cân nhắc
| Phương án | Ưu | Nhược | Lý do loại bỏ |
|---|---|---|---|
| JWT ký trong link | Expiry tích hợp, không cần cột DB | Cần quản lý key; URL dài xấu; subscriber không có account để scope | Quá mức cho một list công khai |
| Email + bước xác nhận | Verify quyền sở hữu | Thêm friction; phá vỡ kỳ vọng một-click | Hại tỷ lệ hoàn tất unsubscribe (và ý định pháp lý một-click) |
| Token UUID ngẫu nhiên | Cùng UX | Cần generator riêng; Snowflake đã có sẵn qua IdGenerator | Snowflake tái dùng hạ tầng sẵn có |
Tham chiếu
packages/core/src/models/schemas/outreach/subscriber/schema.ts(unsubscribeToken$defaultFn,hiddenProperties)packages/outreach/src/services/subscriber.service.ts(unsubscribe)packages/outreach/src/errors/subscriber.errors.ts(UNSUBSCRIBE_INVALID_TOKEN)packages/outreach/src/controllers/subscriber/definitions.ts(routeUNSUBSCRIBE)