---
url: https://docs.nemo12.com/reference/emails/rules.md
description: >-
  Thư Nemo12 trông thế nào, mã nằm đâu, luật chung, mười loại thư, chống trùng,
  theo dõi mở, endpoint và ngừng nhận thư.
---

# Email · Giao diện, luật chung, endpoint và chốt an toàn

Một phần của [Email](./index.md). Thư Nemo12 trông thế nào, mã nằm đâu, luật chung, mười loại thư, chống trùng, theo dõi mở, endpoint và ngừng nhận thư.

## Giao diện: màu biển sâu, bố cục thẻ (SRC-747, đổi màu ở SRC-765)

Hai chỉ đạo trong cùng một ngày, và kết quả cuối là ghép của cả hai:

| Chỉ đạo | Kết quả |
| --- | --- |
| 2026-09-16 sáng (SRC-747): đổi toàn bộ giao diện theo một thư mẫu nền sáng | **Bố cục** mới: thẻ bo góc, header có ô logo và dải trạng thái, nhãn nhỏ trên tiêu đề, chân thư nằm trong thẻ |
| 2026-09-16 chiều (SRC-765): *"lại dùng hệ thống màu sắc này cho tất cả các email"*, kèm ảnh một lá thư biển sâu | **Màu** quay về bảng nguyên bản của SRC-605 |

Bảng màu ở `theme.ts`, một chỗ duy nhất cho cả ba file khuôn thư:

| Token | Mã | Dùng cho |
| --- | --- | --- |
| `BG` · `CARD` · `CARD_SOFT` | `#0a2c50` · `#123a63` · `#0f3459` | Nền ngoài · thẻ thư · chân thẻ |
| `INK_STRONG` · `INK` · `DIM` | `#ffffff` · `#eaf4ff` · `rgba(234,244,255,.72)` | Tiêu đề · chữ chính · chữ phụ |
| `ACCENT` | `#e8590c` | **Chỉ** nút hành động |
| `SOLID` · `WATCH` | `#7ee0a8` · `#ffd479` | "Đang chắc" · "Cần để mắt" |
| `LOGO_BG` · `LOGO_INK` | `#ffffff` · `#0a2c50` | Ô logo: trên nền tối thì ô sáng, chữ trong ô tối |

**Ba luật màu:** cam chỉ dùng cho **một** thứ là nút hành động (hai chỗ cam là bắt người đọc chọn);
**không đỏ** (REQ-UX-03), "cần để mắt" là hổ phách; mọi màu chữ phải đọc được trên `#123a63`, `DIM`
là nhạt nhất được phép cho chữ thật.

**Bố cục cố định của mọi thư**, và thứ tự này là quyết định chứ không phải thói quen:

```
logo + tên · dải trạng thái     ← biết thư của ai trước khi đọc nội dung
─────────────────────────
nhãn nhỏ (eyebrow)
TIÊU ĐỀ                          ← câu trả lời, không phải lời chào
nội dung
[ MỘT nút hành động → ]
mẹo nhỏ (tuỳ chọn)
─────────────────────────
chân thư: nguồn gửi · huỷ đăng ký
```

Chân thư nằm **trong** thẻ chứ không trôi ra nền: một khối chữ mờ trên nền trơn trông như rác của
hộp thư, còn nằm trong thẻ thì nó vẫn là một phần của lá thư. Trang "ngừng nhận thư"
(`unsubscribe.ts`) dùng cùng bảng màu: người bấm nút mà rơi vào một trang lạ hoắc sẽ tưởng mình bấm
nhầm link lừa.

**Một thứ nền tối phải chịu**, nói ra để lần sau khỏi tưởng là lỗi: vài hộp thư ở chế độ dark mode
tự đảo màu chữ, nên thư có thể hiện khác đôi chút giữa các máy. Đổi lại, nó đúng ngôn ngữ hình ảnh
của learn và marlins.

**Đổi bảng màu ở đâu:** sửa khối token trong `theme.ts` là xong cho cả mười tám loại thư. Hai test
(`templates.test.ts`, `digestContent.test.ts`) khoá mã nền và mã nút, nên đổi màu mà quên một chỗ
thì test đỏ ngay - đó là ý đồ, vì một lá thư lạc bảng màu không ai thấy bằng mắt giữa mười tám loại.

## Mã nguồn: một thư mục duy nhất (SRC-744)

Chỉ đạo chủ dự án 2026-09-16: gom hết về một chỗ. Trước đó code thư nằm rải ở ba nơi, và chỗ tệ
nhất là **kênh gửi của mọi thư lại sống trong module `digest`** — muốn đổi nhà cung cấp gửi thư
phải đi vào một module tên là "thư tổng kết hằng ngày". Nay tất cả ở `workers/api/src/modules/email/`:

| File | Việc |
| --- | --- |
| `channel.ts` | Kênh gửi duy nhất (binding `EMAIL` của Cloudflare) + bản đối chứng CC/BCC |
| `tracking.ts` | Ghi sổ `email_sends`, chèn ảnh 1×1, chốt xem trước, danh sách không gửi |
| `dispatch.ts` | Gửi một lần (khoá `email_dispatches`), trần ngày, chạy khô |
| `templates.ts` | `renderLayout` + 10 khuôn thư cho bố mẹ |
| `templatesIelts.ts` | 8 khuôn thư cho IELTS Learner |
| `templatesReferral.ts` | 3 khuôn thư giới thiệu bạn bè — hai người nhận khác nhau |
| `digestContent.ts` | Nội dung thư tổng kết hằng ngày |
| `samples.ts` | Danh mục thư mẫu — nguồn sự thật của trang này |
| `campaigns.ts` | Lượt quét thư cho bố mẹ + ba thư đi ngay trong request |
| `ieltsCampaigns.ts` | Lượt quét thư cho IELTS Learner |
| `campaignsReferral.ts` | Lượt quét thư giới thiệu (gọi lượt quét mốc ở `referral/milestones.ts`) |
| `digestPass.ts` · `digestQuery.ts` | Lượt chạy và truy vấn của thư tổng kết |
| `routes.ts` · `unsubscribe.ts` | Ảnh theo dõi lượt mở · nút ngừng nhận thư |

Ngoài thư mục này **không còn file nào dựng hay gửi thư**. `shared/qcEmails.ts` có chữ "email"
trong tên nhưng không liên quan: đó là danh sách địa chỉ có quyền Quality Control, không phải thư.

## Luật chung của mọi thư

| # | Luật | Vì sao |
| --- | --- | --- |
| 1 | **Một khung duy nhất** — mọi thư đi qua `renderLayout` trong `templates.ts`, màu lấy từ `theme.ts` | Hai phong cách trong cùng một hộp thư trông như hai công ty |
| 2 | **Dòng tiêu đề + câu đầu phải tự đứng được** | Bố mẹ đọc trên điện thoại, phần lớn chỉ đọc tới đó |
| 3 | **Không chấm điểm con, không màu đỏ** (REQ-UX-03) | "Cần để mắt" dùng hổ phách `#ffd479`, "đang chắc" dùng xanh `#7ee0a8` |
| 4 | **Đúng MỘT nút hành động** | Hai nút là bắt bố mẹ chọn lúc 6h sáng |
| 5 | **Style inline, layout `<table>`, không ảnh ngoài, không webfont** | Gmail cắt `<style>`, Outlook render bằng Word engine |
| 6 | **Bản đối chứng về `EMAIL_COPY_TO`** (`dac2205@gmail.com`), mặc định **CC** | Chủ dự án phải thấy đúng thứ khách hàng nhận. Xem cảnh báo bên dưới |
| 7 | **Idempotent** — mỗi (người, sự kiện) một thư, chốt bằng khoá chính trong DB | Email không hoàn tác được; cron chạy lại không được sinh thư thứ hai |
| 8 | **Thư nào cũng đi qua `sendTrackedEmail`** — ghi sổ `email_sends` và mang ảnh theo dõi | Thư gửi mà không để lại dấu là thư không ai kiểm được |
| 9 | **Thư sinh từ SỰ KIỆN, không sinh từ lịch** | Thư gửi vì "đến hẹn" là thư rác có thương hiệu |
| 10 | **Tối đa 3 thư một người một ngày** (`EMAIL_MAX_PER_DAY`, ngày lịch Việt Nam) | Hộp thư của một gia đình là tài nguyên chung của cả mười loại thư |
| 11 | **Dòng tiêu đề gọi đích danh một người** (SRC-1004) — xem mục bên dưới | Hai lá cùng dạng không có tên thì trông y hệt nhau, và thư không gọi ai là thư gửi hàng loạt |

Địa chỉ gửi: `DIGEST_FROM` = `NEMO <support@nemo12.com>`. Kênh gửi là **binding
`EMAIL`** (Cloudflare Email Service, SRC-602) — không có khoá API nào để lộ hay hết hạn.
Thư NEMO IELTS đi từ `NEMO IELTS <ielts@nemo12.com>`, thư SAT từ `NEMO SAT <sat@nemo12.com>`
(SRC-1091, xem mục "Mỗi thư thuộc một chương trình" bên dưới).

> ⚠️ **CC là địa chỉ nhìn thấy được.** Chỉ đạo 2026-09-07 chốt dùng CC. Nghĩa là phụ huynh mở thư
> sẽ **thấy `dac2205@gmail.com` trong danh sách người nhận**, và nút "Trả lời tất cả" của họ gửi
> thẳng vào đó. BCC làm đúng việc đối chứng ấy mà không lộ. Vì vậy cách gắn bản sao là một **công
> tắc cấu hình**: đặt `EMAIL_COPY_MODE=bcc` trong `wrangler.jsonc` là đổi, không cần sửa mã.
> Trang minh bạch dữ liệu khai đúng chế độ đang bật cho phụ huynh đọc.

## Mười loại thư

Thứ tự dưới đây là **thứ tự hành trình của một gia đình**, không phải thứ tự viết mã. Đọc từ trên
xuống là thấy bố mẹ nghe thấy gì từ Nemo12 theo thời gian — và thấy ngay chỗ nào đang im lặng quá lâu.

| # | Loại (`id`) | Kích hoạt thế nào (chạy thật) | Giúp ích gì |
| --- | --- | --- | --- |
| 1 | `welcome-parent` | **Ngay** khi tài khoản mới được tạo ở lần đăng nhập đầu — `auth/routes.ts`, gửi sau lưng request | Ba bước để hệ thống hiểu con |
| 2 | `setup-nudge` | Lượt quét 19:00 VN: tài khoản tạo quá 48 giờ mà chưa có đứa con nào. Cửa sổ 7 ngày, **một đời một thư** | Kéo qua nốt bước còn thiếu |
| 3 | `diagnostic-ready` | **Ngay** khi một phiên `diagnostic` chuyển sang `completed` — `knowledge/routes.ts` | Bức tranh đầu tiên, kèm câu "đây không phải điểm" |
| 4 | `daily-digest` | Cron `0 19 * * *` (02:00 VN), chỉ cho con **có học** hôm đó | Hôm qua con học gì |
| 5 | `weekly-report` | Cùng nhịp 02:00 VN nhưng **chỉ thứ Hai** — tổng kết bảy ngày vừa khép lại | Xu hướng tuần + đúng một việc cho tuần tới |
| 6 | `milestone` | Lượt quét 19:00 VN: `mastery ≥ .85` **và** `confidence ≥ .6` **và** `evidence_count ≥ 5`. Mỗi kỹ năng một lần, **tối đa 1 thư/tuần/con** | Khen đúng lúc, khen CÁCH làm được |
| 7 | `inactivity-nudge` | Lượt quét 19:00 VN: bài cuối cách đây **5–30 ngày**. **Tối đa 1 thư/7 ngày/con** | Quay lại bằng một bài 10 phút |
| 8 | `exam-countdown` | Lượt quét 19:00 VN: `semester_exam_schedule` còn đúng **30 / 14 / 3** ngày | Chắc mấy phần, cần ôn mấy phần, kế hoạch đổi theo mốc |
| 9 | `trial-reminder` | Lượt quét 19:00 VN: buổi **số 1** của một khoá diễn ra **ngày mai** | Giảm vắng mặt; danh sách mang theo lấy từ chính khoá |
| 10 | `event-confirmation` | **Ngay** sau khi một đăng ký chuyển sang `joined` — `events/routes.ts` | Vé xác nhận + một chạm nhường chỗ |

Ba thư "ngay" đi qua `waitUntil`: **không chắn đường request**. Một người không vào được tài khoản
của mình vì cái hộp thư là chuyện không chấp nhận được.

## Chống gửi trùng và trần tần suất

Bảng `email_dispatches` (migration 0206) là **khoá**: giành hàng trước, gửi sau. Khoá chính là
`(template_id, dedupe_key)`, và `dedupe_key` do mã sinh cho từng loại — `user_id` với welcome,
`exam_id:30` với đếm ngược, `learner_id:node_id:email` với mốc. Cron chạy lại, hay ai đó bấm chạy
tay, đều không sinh lá thư thứ hai.

Trần tần suất (mốc: 1 thư/tuần/con; nghỉ dài: 1 thư/7 ngày) đọc từ `email_sends` chứ không từ bảng
khoá: câu hỏi là "hộp thư của họ đã nhận bao nhiêu", nên một thư giành chỗ rồi gửi hỏng không được
tính là đã làm phiền ai.

Nguồn sự thật của bảng này: `workers/api/src/modules/email/samples.ts` — mỗi loại thư một bản mẫu,
dùng chung cho tài liệu và cho nút gửi thử.

**Danh mục mẫu đủ cả 21 lá** kể từ 20.09.2026: mười thư phụ huynh, ba thư giới thiệu, tám thư IELTS
của SRC-737, và hai thư mới của SRC-927/928. Tám lá IELTS từng chạy thật trên production suốt từ
15.09 mà không gửi thử được — tức là không ai rà được bằng mắt trước khi người học nhận chúng.

**Chín lá learner dùng chung MỘT bộ số.** Cùng một người tưởng tượng: Minh, xuất phát Overall 5.5,
đích 7.0, hẹn ngày 20.12.2026, chặng nặng nhất là Writing — và cũng chính Writing là kỹ năng lên
band trong `ielts-skill-up`. Lý do: các bản mẫu này về sau sẽ được đọc LIỀN MẠCH trong một hộp thư,
nên chúng phải kể được một câu chuyện có thật. Một bộ mẫu mà mỗi lá một người sẽ giấu đúng loại lỗi
hay xảy ra nhất trong thư thật — hai con số cùng nói về một người mà không khớp nhau.

## Theo dõi lượt mở

Chỉ đạo 2026-09-07: *"Hệ thống cần tracking được ai mở email vào những thời điểm nào."*

Cách làm — cách duy nhất tồn tại, vì không hộp thư nào báo cho người gửi biết:

1. Mỗi thư gửi đi ghi một dòng `email_sends` kèm một **token 32 byte** từ CSPRNG.
2. Thân thư mang một ảnh trong suốt 1×1: `GET https://api.nemo12.com/e/o/{token}.gif`.
3. Hộp thư tải ảnh đó → một dòng `email_opens` (thời điểm, trình đọc rút gọn 120 ký tự,
   `fetch_kind`). **Mở lại là một dòng mới** — câu hỏi là "những thời điểm nào", số nhiều.

Route ảnh **công khai và nằm ngoài `/v1`**: hộp thư tải ảnh bằng máy chủ của Gmail/Apple nên không
mang cookie của ai, quyền nằm trong chính token; và địa chỉ đã in vào thư phải sống lâu hơn mọi
phiên bản API. Token sai trả về đúng tấm ảnh y hệt token đúng, nên không ai dò được token nào từng
tồn tại. Cùng lý lẽ với feed lịch (SDD-031 §5).

**Ba giới hạn phải nhớ trước khi tin vào số:**

| Giới hạn | Hệ quả |
| --- | --- |
| Gmail tải ảnh qua proxy của Google; Apple Mail Privacy Protection tải **trước** khi người dùng mở | Một lượt mở gần với "thư đã tới hộp thư đang hoạt động" hơn là "người đã đọc". Vì thế mới có cột `fetch_kind` |
| Người tắt tải ảnh thì mở thật cũng không ghi được | Số mở luôn là **cận dưới**, không bao giờ là số đúng |
| Cả hai điều trên | **Đừng dựng quyết định sản phẩm nào trên riêng tỉ lệ mở** |

Quyền riêng tư: **không lưu địa chỉ IP** — IP của phụ huynh không trả lời câu hỏi sản phẩm nào, mà
lưu là gánh thêm một loại dữ liệu cá nhân phải khai và phải xoá. Việc theo dõi này được khai thẳng
cho phụ huynh trên trang minh bạch dữ liệu, cùng chỗ khai bản đối chứng. Giữ `email_sends` 730 ngày,
`email_opens` 365 ngày (`shared/privacy.ts`).

## Endpoint

| Đường | Việc |
| --- | --- |
| `GET /v1/admin/email-samples` | Danh mục 26 bản mẫu: id, trạng thái nối dây, trigger, tiêu đề |
| `POST /v1/admin/email-samples` | Gửi thử. Body `{"ids": […]}` hoặc `{}` để gửi tất cả |
| `GET /v1/admin/email-sends` | Sổ thư đã gửi kèm **danh sách thời điểm mở** từng thư. Lọc `?to=` `?template_id=` `?limit=` |
| `GET /e/o/{token}.gif` | Ảnh theo dõi. Công khai, không phiên |
| `GET /v1/admin/email-pass-preview` | **Chạy khô** lượt quét: tối nay ai nhận gì, ai bị hoãn vì trần. Không gửi, không ghi sổ, không giành hàng chống trùng. `?at=` giả lập mốc thời gian, `?weekly=1` chấm báo cáo tuần |
| `GET /v1/admin/email-suppressions` | Ai đang bị chặn, vì sao, ai chặn, đã gửi bao nhiêu thư trước đó |
| `POST /v1/admin/email-suppressions` | Chặn thêm (`scope` + `key` + `reason`, lý do bắt buộc) |
| `DELETE /v1/admin/email-suppressions?scope=&key=` | Bỏ chặn. Ghi nhật ký truy cập, vì đây là mở lại đường thư tới một người thật |

Cột nút của bảng chặn được **ghim vào mép phải** (`stickyLast` của `ui.tsx`) và id learner rút còn 8
ký tự kèm `title` đầy đủ — SRC-689, sau khi đo thật trên màn 1560px thấy nút "Bỏ chặn" bị đẩy hẳn
ra ngoài. Một màn mà hành động chính phải cuộn ngang mới thấy là màn hỏng, dù dữ liệu vẫn tới được.

Cả ba đường trên có màn giao diện ở **admin.nemo12.com → tab Email** (SRC-686): danh sách chặn kèm
nút bỏ chặn có hỏi lại, ô thêm chặn tay, và sổ thư gần đây kèm số lượt mở. Trước khi có màn này, bỏ
chặn một người là gõ tay một câu `DELETE` lên D1 production — một thao tác chỉ làm được bằng SQL gõ
tay là thao tác sẽ có ngày gõ nhầm.

Thư mẫu ghi sổ với `template_id` mang tiền tố `sample:` nên **không tiêu suất trần ngày** của thư
thật (SRC-764). Trước đó nó có: `POST /v1/admin/email-samples` gửi qua chính `sendTrackedEmail`, mà
hàm đó ghi một dòng `email_sends` `status='sent'` như mọi thư thường, và bộ đếm trần đọc đúng bảng
ấy. Ngày 16.09.2026 hộp quản trị lên **49/3** sau bốn lượt gửi thử, và mọi thư thật tới địa chỉ đó
tối hôm đó đều bị hoãn. Vẫn giữ dòng trong sổ thay vì không ghi, vì dòng đó là chỗ neo của ảnh theo
dõi lượt mở và nút ngừng nhận thư, mà xem trước lượt mở chính là việc người ta bấm gửi thử để làm.

**Nút gửi thư mẫu** ở admin.nemo12.com → tab Email, trên cùng (SRC-1004). Trước đó endpoint này chỉ
gọi được bằng một câu `fetch` dán vào DevTools Console, và chủ dự án đã đợi thư mẫu suốt một ngày mà
không có nút nào để bấm. Chọn từng lá, cả nhóm NEMO IELTS, hoặc tất cả; quá 10 lá thì màn tự chia
lượt (server nhận tối đa 10 id mỗi lần). Kết quả hiện **từng lá** kèm lý do khi `skipped`/`failed`.

`POST /v1/admin/email-samples` **không nhận địa chỉ người nhận**: thư luôn đi về đúng
`EMAIL_SAMPLE_TO` (hộp thư thử của chủ dự án, 07.10.2026), rỗng thì `EMAIL_COPY_TO`. Một route gửi thư nhận `to` tự do là máy phát thư rác chỉ cách một phiên admin bị
mượn. Tiêu đề bản thử gắn nhãn `[MẪU]`.

## Danh sách không gửi

Bảng `email_suppressions` (migration 0207) trả lời đúng một câu: **đừng gửi thư cho đối tượng này**.

| `scope` | `key` | Chặn gì |
| --- | --- | --- |
| `learner` | `learner_id` | Mọi thư **nói về** đứa con đó |
| `email` | địa chỉ viết thường | Mọi thư **gửi tới** địa chỉ đó |

Hai chiều đều cần: một nhà có hai con, tắt thư về một đứa không được làm câm luôn thư về đứa kia.

Cách rẻ hơn là sửa `learners.status` thành `archived` — và đó là cách sai: trạng thái learner điều
khiển cả việc học, báo cáo và quyền truy cập, nên tắt thư bằng cách xoá sổ một đứa trẻ là dùng dao
mổ trâu, và ba tháng sau không ai còn nhớ vì sao hồ sơ đó bị archive. `reason` là cột bắt buộc vì
một dòng chặn thư không có lý do là một dòng không ai dám xoá.

Kiểm ở **hai chỗ**, cố ý: `dispatchOnce` kiểm trước khi giành hàng chống trùng (chặn mà vẫn tiêu
suất thì người xin nhận lại sẽ không bao giờ nhận được lá thư một-đời-một-lần), và `sendTrackedEmail`
kiểm lần cuối vì đó là chỗ hẹp nhất mọi thư đều đi qua. Thư bị chặn **không ghi vào `email_sends`**:
nó không tồn tại, không phải thư gửi hỏng.

Nút "ngừng nhận thư" của phụ huynh ghi thẳng vào bảng này với `scope='email'` — xem mục dưới.

## Ngừng nhận thư (SRC-685)

Mọi thư mang một đường ngừng nhận ở chân thư, có ở **cả bản HTML lẫn bản chữ trơn** — một lối ra
chỉ tồn tại trong bản HTML là lối ra nửa vời. Địa chỉ dùng **chung token** với ảnh theo dõi: cùng
một lá thư, cùng một bí mật.

| Đường | Việc |
| --- | --- |
| `GET /e/u/{token}` | **Chỉ hỏi** — hiện trang xác nhận, không ghi gì |
| `POST /e/u/{token}` | Ghi `email_suppressions(scope='email')` cho đúng địa chỉ của lá thư đó |

**Vì sao hai bước:** trình quét link của Gmail, Outlook và mọi phần mềm an ninh doanh nghiệp tự mở
mọi URL trong thư để kiểm virus. Một nút làm việc ngay ở `GET` sẽ tự huỷ đăng ký cho hàng loạt phụ
huynh chưa hề bấm gì — và không ai biết cho tới khi số người nhận tụt.

**Không đòi đăng nhập:** người muốn thoát khỏi một hộp thư không nên bị bắt tạo mật khẩu để thoát.
Quyền nằm trong token của lá thư. Token sai và token không tồn tại trả về **cùng một trang**, nên
không ai dò được token nào từng có.

Trang nói thật cái người dùng sẽ mất và **không dụ ở lại**: người bị giữ bằng một câu mập mờ sẽ bấm
"Báo spam" ở lần sau, và một lần bị báo spam hại hơn nhiều so với mất một người nhận. Trang cũng nói
rõ dữ liệu học của con vẫn nguyên vẹn — ngừng thư không phải xoá tài khoản.

**Chưa có:** header `List-Unsubscribe` (RFC 8058) để Gmail hiện nút ngừng nhận ngay cạnh tên người
gửi. Binding `EMAIL` của Cloudflare hiện không cho đặt header tuỳ ý; khi nào cho thì đây là việc
đáng làm sớm, vì nút của Gmail là nút người ta bấm thay cho nút "Báo spam".

## Chốt an toàn khi thử trên production

`DIGEST_TEST_RECIPIENT` — chừng nào còn đặt, **mọi thư của cả mười loại** đi về đúng địa chỉ đó thay
vì tới phụ huynh thật, tiêu đề gắn `[THỬ → địa-chỉ-đáng-lẽ-nhận]`. Chốt này nằm trong
`sendTrackedEmail`, tức chỗ hẹp nhất mà mọi thư đều đi qua — đặt ở tầng cao hơn thì loại thư nối
dây sau sẽ lọt qua chốt mà không ai nhận ra.

Chế độ xem trước **không tiêu suất chống trùng**: hàng vừa giành trong `email_dispatches` được trả
lại ngay sau khi gửi (`dispatch.ts`). Giữ lại hàng đó là biến một lượt xem trước thành một lá thư
vĩnh viễn không tới tay người cần — `welcome-parent` và `setup-nudge` là một đời một thư. Đánh đổi:
chạy hai lượt xem trước trong cùng một tối sẽ gửi hai bản về hộp quản trị. Nhận hai bản giống nhau
trong hộp thư của chính mình là phiền; mất hẳn một lá thư của một gia đình là hỏng việc.

**Trạng thái hiện tại (2026-09-07): chốt đang TẮT — thư đi thật tới phụ huynh.**
`DIGEST_TEST_RECIPIENT = ""`. Chủ dự án đã xem lượt quét 19:00 ngày 07/09 (7 thư về hộp quản trị)
và chốt bật gửi thật. Muốn xem trước lần nữa thì đặt lại địa chỉ vào biến đó trong `wrangler.jsonc`.

## Trace

| Doc | Nguồn | Thoả |
| --- | --- | --- |
| ref.emails | SRC-452, SRC-602, SRC-605, SRC-675, SRC-682, SRC-683, SRC-684, SRC-685, SRC-686, SRC-687, SRC-689, SRC-690, SRC-737, SRC-742, SRC-744, SRC-747 | REQ-PAR-12, REQ-DOC-04 |
