Skip to content

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

Một phần của Email. 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ỉ đạoKế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ángBố 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âuMà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ư:

TokenMãDùng cho
BG · CARD · CARD_SOFT#0a2c50 · #123a63 · #0f3459Nề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#e8590cChỉ 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/:

FileViệc
channel.tsKênh gửi duy nhất (binding EMAIL của Cloudflare) + bản đối chứng CC/BCC
tracking.tsGhi sổ email_sends, chèn ảnh 1×1, chốt xem trước, danh sách không gửi
dispatch.tsGửi một lần (khoá email_dispatches), trần ngày, chạy khô
templates.tsrenderLayout + 10 khuôn thư cho bố mẹ
templatesIelts.ts8 khuôn thư cho IELTS Learner
templatesReferral.ts3 khuôn thư giới thiệu bạn bè — hai người nhận khác nhau
digestContent.tsNội dung thư tổng kết hằng ngày
samples.tsDanh mục thư mẫu — nguồn sự thật của trang này
campaigns.tsLượt quét thư cho bố mẹ + ba thư đi ngay trong request
ieltsCampaigns.tsLượt quét thư cho IELTS Learner
campaignsReferral.tsLượt quét thư giới thiệu (gọi lượt quét mốc ở referral/milestones.ts)
digestPass.ts · digestQuery.tsLượ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ậtVì sao
1Một khung duy nhất — mọi thư đi qua renderLayout trong templates.ts, màu lấy từ theme.tsHai phong cách trong cùng một hộp thư trông như hai công ty
2Dòng tiêu đề + câu đầu phải tự đứng đượcBố mẹ đọc trên điện thoại, phần lớn chỉ đọc tới đó
3Khô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 độngHai nút là bắt bố mẹ chọn lúc 6h sáng
5Style inline, layout <table>, không ảnh ngoài, không webfontGmail cắt <style>, Outlook render bằng Word engine
6Bản đối chứng về EMAIL_COPY_TO (dac2205@gmail.com), mặc định CCChủ dự án phải thấy đúng thứ khách hàng nhận. Xem cảnh báo bên dưới
7Idempotent — mỗi (người, sự kiện) một thư, chốt bằng khoá chính trong DBEmail không hoàn tác được; cron chạy lại không được sinh thư thứ hai
8Thư nào cũng đi qua sendTrackedEmail — ghi sổ email_sends và mang ảnh theo dõiThư gửi mà không để lại dấu là thư không ai kiểm được
9Thư sinh từ SỰ KIỆN, không sinh từ lịchThư gửi vì "đến hẹn" là thư rác có thương hiệu
10Tố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ư
11Dòng tiêu đề gọi đích danh một người (SRC-1004) — xem mục bên dướiHai 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ì
1welcome-parentNgay khi tài khoản mới được tạo ở lần đăng nhập đầu — auth/routes.ts, gửi sau lưng requestBa bước để hệ thống hiểu con
2setup-nudgeLượ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
3diagnostic-readyNgay khi một phiên diagnostic chuyển sang completed — knowledge/routes.tsBức tranh đầu tiên, kèm câu "đây không phải điểm"
4daily-digestCron 0 19 * * * (02:00 VN), chỉ cho con có học hôm đóHôm qua con học gì
5weekly-reportCùng nhịp 02:00 VN nhưng chỉ thứ Hai — tổng kết bảy ngày vừa khép lạiXu hướng tuần + đúng một việc cho tuần tới
6milestoneLượ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/conKhen đúng lúc, khen CÁCH làm được
7inactivity-nudgeLượ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/conQuay lại bằng một bài 10 phút
8exam-countdownLượt quét 19:00 VN: semester_exam_schedule còn đúng 30 / 14 / 3 ngàyChắc mấy phần, cần ôn mấy phần, kế hoạch đổi theo mốc
9trial-reminderLượt quét 19:00 VN: buổi số 1 của một khoá diễn ra ngày maiGiảm vắng mặt; danh sách mang theo lấy từ chính khoá
10event-confirmationNgay sau khi một đăng ký chuyển sang joined — events/routes.tsVé 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ạnHệ 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 đượcSố 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 ​

ĐườngViệc
GET /v1/admin/email-samplesDanh mục 26 bản mẫu: id, trạng thái nối dây, trigger, tiêu đề
POST /v1/admin/email-samplesGửi thử. Body {"ids": […]} hoặc {} để gửi tất cả
GET /v1/admin/email-sendsSổ 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-previewChạ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-suppressionsAi đang bị chặn, vì sao, ai chặn, đã gửi bao nhiêu thư trước đó
POST /v1/admin/email-suppressionsChặ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.

scopekeyChặn gì
learnerlearner_idMọi thư nói về đứa con đó
emailđịa chỉ viết thườngMọ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.

ĐườngViệ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 ​

DocNguồnThoả
ref.emailsSRC-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-747REQ-PAR-12, REQ-DOC-04