Skip to content

Nhịp thư (Email Orchestrator): SDD ​

PRD · Kế hoạch · Tests · trạng thái: đã viết mã, chạy preview (14.10.2026) · vận hành ở platform/emails

Nguồn sự thật là mã ở api.conan.school/src/shared/email/orchestrator/. Trang này mô tả mã như nó đang chạy; chỗ nào lệch thì mã đúng và trang này phải sửa. Số mục (5, 6.1, 9, 10...) được mã trích trong chú thích, đừng đánh số lại.

1. Luồng tổng thể ​

Một cron duy nhất (7 * * * *, đã có) lái mọi việc qua attempt('email-orchestrator', ...) trong src/index.ts. Không thêm cron, không thêm Queue. Lượt cron chỉ quyết định tạo instance workflow nào; việc nặng chạy trong EmailOrchestratorWorkflow, id instance đặt tên là khoá chống trùng.

2. Bảng D1 ​

Migration 20261114a_email_orchestrator.sql. Họ bảng không tiền tố, ghi bằng env.DB.prepare, giống email_deliveries (không đi qua getD1Client, vì nó tự gắn legacy_).

BảngKhoáVai trò
email_orch_settingskeyCài đặt của Nhịp thư (mục 14). Bảng riêng, KHÔNG dùng platform_settings: mọi mentor ghi được platform_settings qua /api/mentor/site-settings, nên công tắc gửi thư cho hơn một nghìn người không được nằm ở đó. Chỉ admin (users.write) ghi được, qua danh sách khoá cho phép. Migration gieo 11 khoá mặc định.
email_plan_runsplan_dateMột dòng mỗi ngày gửi: trạng thái planning → planned → previewed (hoặc failed), mode lúc lập, khoá thuê lease_until, attempts, số đếm, cap, stats_json, preview_at (bảng kế hoạch đã gửi), held_at/held_by (giữ cả ngày), error.
email_plan_kinds(plan_date, kind)Mỗi loại trong kế hoạch: số dòng, người gửi, dữ liệu mẫu, sample_subject, fingerprint, preview_ok, khoá hai pha preview_claimed_at + preview_attempts, preview_at, preview_error, held_at/held_by/hold_reason, stale_at.
email_plan_items(plan_date, email_key)Một dòng mỗi người mỗi ngày. Khoá chính là cơ chế "tối đa một thư", không có câu SELECT nào thay được nó. Lưu mọi id của địa chỉ (user_ids), phân khúc, tầng, loại, ref, người gửi, send_at, điểm, lý do chọn, ứng viên thua, dữ liệu dựng thư, trạng thái, attempt_at.
email_sent_refs(email_key, kind, ref_id)Chốt chống lặp do CSDL cưỡng chế: một (địa chỉ, loại, mục) chỉ gửi một lần. Kế hoạch đã tránh lặp bằng ĐỌC; bảng này là chốt cuối ngay trước lúc gửi. Gửi không tới ai (hạn mức, tốc độ, hỏng) thì xoá dòng để lần sau thử lại.
email_clicksidLượt bấm qua link theo dõi có chữ ký; is_bot cho máy quét, HEAD, UA rỗng và lượt bấm < 10 giây.
email_audience_prefsemail_keyNhịp (auto/weekly/important/none), nhóm bị tắt, promo_consent_at, câu trả lời reconnect + lúc trả lời, consent_version, source. Khoá theo địa chỉ, không theo id: 57 người có hai id.
email_deliveries (+2 cột)from_email (alias đã dùng), plan_date. Thêm chỉ mục (lower(recipient), created_at) cho phép kiểm "địa chỉ này đã nhận thư hôm nay chưa".

Trạng thái của email_plan_items.status (CHECK trong migration):

Trạng tháiNghĩa
plannedChờ tới send_at
over_capĐủ điều kiện nhưng vượt trần ngày, lúc lập kế hoạch hoặc lúc gửi (skip_reason = cap_at_dispatch); lưu lại để thấy trần đang cắt bao nhiêu
sendingĐã giành dòng, đang gửi
sent / failedKết quả của sendEmail; delivery_id trỏ sang sổ gửi, skip_reason của failed là mã lỗi
skippedBị cổng chặn; skip_reason ghi vì sao
deferredDừng vì hạn mức: E_DAILY_LIMIT_EXCEEDED hoặc soft_ceiling
simulatedChế độ preview: tới giờ nhưng không gửi
expiredChưa gửi khi đã quá 22:00 ngày D, hoặc ngày D đã qua
unknownKẹt ở sending quá 30 phút; không bao giờ gửi lại (thà mất một thư còn hơn gửi hai)

skip_reason của dòng skipped: preview_window (dời giờ quá 21:00), class_not_live, preview_missing, held, template_changed, sender_error, opted_out, already_mailed_today, gap, no_longer_valid, already_sent_ref. Ngày bị giữ cả ngày thì dòng không bị giành, ở lại planned rồi thành expired.

3. Module ​

api.conan.school/src/shared/email/
  catalog.ts                  EMAIL_CATALOG đầy đủ = lõi (templates.ts) + Nhịp thư; EMAIL_KINDS (34 loại)
  templates.ts                lõi (19 loại); EmailKind; ENGAGEMENT_KINDS = 4 loại cũ + NEW_ORCH_KINDS;
                              export layout()/tieuDe()/esc/fmt*; opts.preheader
  orchestrator/
    types.ts                  NEW_ORCH_KINDS (14), ORCH_KINDS (18), KindClass, Category, MUTABLE_CATEGORIES,
                              Tier, AudienceMember, Candidate, PlanItemRow; KHÔNG import giá trị từ
                              templates.ts (tránh vòng import)
    config.ts                 SENDERS, COMMUNITY_SENDERS, ALL_SENDER_ADDRESSES, REVIEWERS, KIND_META,
                              hằng số chấm điểm, DEFAULT_SETTINGS, đọc/ghi cài đặt, orchestratorOwnsDaily()
    time.ts                   ngày/giờ VN, so chuỗi ISO
    score.ts                  HÀM THUẦN: tầng, p, Thompson có hạt giống, giá trị, mỏi, cổng, chọn, trần
    audience.ts               loadAudience(), loadKindStats(): đọc theo lô cho cả vũ trụ
    generators/*.ts           relationship, newsletter, evergreen, lifecycle, journey: generate(ctx) + stillValid(env, item)
    catalog-*.ts              mẫu thư của 14 loại mới + bảng kế hoạch (orch_preview_digest)
    journey-days.ts           CHỮ của 30 thư làm quen + giọng 18 cộng đồng; catalog-journey.ts dựng khung
    links.ts                  ký/kiểm chữ ký (prefs, reconnect, hold, click), LINK_PLACEHOLDERS, viết lại link
    planner.ts                lease → audience → ứng viên → chọn → trần → ghi
    preview.ts                fingerprintOf(), sendPreviews(): mẫu từng loại + bảng kế hoạch, dời send_at
    dispatch.ts               selectDue(), sendBatch(), deferRemaining(), tripToPreview(), markSimulated(), sweepPast()
    tick.ts                   orchestratorTick(): gọi từ cron mỗi giờ
  send.ts                     người gửi + miền, Reply-To, [QC], xem trước, prefsBlock, theo dõi bấm,
                              from_email/plan_date, thử lại một lần khi thư giao dịch chạm tốc độ
src/workflows/email-orchestrator.ts     EmailOrchestratorWorkflow (action plan | dispatch)
src/domains/system/email-orch.ts         trang có chữ ký: /c, /prefs, /reconnect, /plan/hold, /unsubscribe
src/domains/admin/email-orchestrator.ts  /api/admin/emails/orchestrator/*
admin.conan.school/legacy/admin-trial/src/pages/EmailOrchestratorPage.tsx   trang "Nhịp thư"
tests/email-orchestrator.test.mts       npm run test:orchestrator

templates.ts có ~910 dòng, trần qc-scan là 1.000 và mốc "file khổng lồ" của api đã khoá; các mẫu mới không được nằm trong đó. EmailKind vẫn khai ở templates.ts (chỉ là chuỗi, lấy NewOrchKind từ types.ts), mẫu mới nằm ở orchestrator/catalog-*.ts, và catalog.ts ghép hai nửa: import một chiều, không vòng. Mọi chỗ tra mẫu theo loại (cổng gửi, admin, smoke, test) import EMAIL_CATALOG từ catalog.ts.

4. Người gửi ​

SENDERS (6) + COMMUNITY_SENDERS (18 cộng đồng đang mở) trong config.ts là nguồn duy nhất. ALL_SENDER_ADDRESSES nhân chúng với hai miền SENDER_DOMAINS = conan.school, tin.conan.school, ra 48 địa chỉ; wrangler.toml khai đúng 48 địa chỉ đó trong allowed_sender_addresses, và tests/email-orchestrator.test.mts so hai bên từng địa chỉ, cả hai chiều.

ts
hello   { email: '[email protected]',   name: 'Conan School' }
hoc     { email: '[email protected]',     name: 'Học cùng Conan' }
sukien  { email: '[email protected]',  name: 'Sự kiện Conan' }
ran     { email: '[email protected]',     name: 'Ran ở Conan' }
thuvien { email: '[email protected]', name: 'Thư viện Conan' }
pro     { email: '[email protected]',     name: 'Conan Pro' }
nha     theo cộng đồng: food@, drink@, ..., fitness@ (health-fitness), travel@ (hospitality-travel),
        people@ (people-hr), cs@ (customer-success), ai@ (ai-technology); tên 'Nhà <tên> · Conan'

resolveSender(key, communitySlug, domain): nha không có cộng đồng (hay cộng đồng lạ) thì rơi về hello; phần trước @ giữ nguyên, phần miền lấy từ cài đặt sender_domain. sendEmail nhận sender, communitySlug, senderDomain; thư tài khoản không truyền gì nên luôn là Conan School <[email protected]>. Mọi From khác [email protected] (kể cả [email protected]) mang replyTo: '[email protected]'. Sổ gửi ghi from_email.

Không có đường "gửi lại bằng hello@ khi alias bị từ chối" kiểu PR #351: lỗi người gửi (E_SENDER_*) giữ cả loại đó trong ngày (held_by = 'system', hold_reason = mã lỗi) và hiện ở admin, thay vì âm thầm đổi danh tính người gửi. Thả giữ ở admin sau khi sửa alias.

5. Vũ trụ người nhận và đặc trưng (audience.ts) ​

Khoá: email_key = lower(trim(email)). Nguồn: users (status='active') và legacy_users (COALESCE(is_active,1)=1, email không rỗng, account_type <> 'seed_forum': 54 tác giả hư cấu của diễn đàn mẫu). user_id chính tắc: id ở users nếu có, nếu không là id legacy_users mới nhất. allIds giữ mọi id của địa chỉ ở cả hai bảng. segment: account hoặc legacy.

Mỗi truy vấn đọc theo lô cho cả vũ trụ (~15 câu), không theo người (vòng cũ tốn 7-10 câu mỗi người, nhân 1.264 là vượt trần 1.000 câu mỗi lần gọi của D1):

Đặc trưngNguồn
Đã huỷ / bị chặnemail_preferences theo MỌI id của địa chỉ; trả lời "Không" ở reconnect cũng là huỷ
Nhịp, nhóm tắt, đồng ý ưu đãi, trả lời reconnectemail_audience_prefs
minor (chưa đủ 16 tuổi)learner_guardians.user_id ∪ legacy_users có birth_year (hoặc 4 ký tự đầu birth_date) > năm gửi − 16
Lần cuối vào webMAX(member_sessions.last_seen_at)
Hoạt động học 7/30 ngàylearner_behavior_evidence, course_material_progress, course_unit_overview_views (UNION ALL 3 vế: D1 từ chối câu ghép quá 5 vế). seen_at lưu YYYY-MM-DD HH:MM:SS nên bọc strftime('%Y-%m-%dT%H:%M:%fZ', seen_at) trước khi so
Cộng đồng, Procommunity_memberships ⋈ communities (status='active'), nhà trước
Lịch sử gửi theo loạiemail_deliveries 365 ngày, status='sent', audit_of IS NULL, bỏ ref_id smoke/admin-test; gộp theo (lower(recipient), kind): số lần, lần cuối, group_concat(ref_id)
Bằng chứng tương tác (đã giảm theo tuổi)gộp trong SQL, chỉ thư gắn kết, xem 6.2
8 thư gắn kết gần nhất + có tương tác khôngROW_NUMBER() OVER (PARTITION BY lower(recipient)), để đếm "bao nhiêu thư liền không tương tác"
Lịch sử ảoemail_plan_items của 8 ngày trước ngày gửi, trạng thái planned/sending/unknown (cộng simulated khi mode=preview), xem dưới
Lịch sử hệ cũlegacy_email_logs status='sent' (đã gửi/mở/bấm thời Resend)
Sở thích người hệ cũlegacy_workshop_registrations ⋈ workshops: workshop AI → ai-technology, nội dung/viral/marketing → marketing

Lịch sử ảo (lỗi chặn của rà soát thiết kế). Kế hoạch ngày D lập lúc 07:07 ngày D-1, TRƯỚC khi thư ngày D-1 đi lúc 08:00, nên sổ gửi chưa có chúng. Chỉ đọc sổ gửi thì cùng một người nhận cùng một câu chuyện hai ngày liền, và "tối đa 1 thư/tuần" thành hai thư trong hai ngày. Dòng kế hoạch chưa gửi của các ngày trước được cộng vào history (số lần, lần cuối, ref) và vào recent với cờ virtual: tính cho khoảng cách, cooldown, chống lặp, mỏi và "cùng loại với thư gần nhất", nhưng không tính vào chuỗi im lặng (chưa biết người ta có đọc không). Ở preview, dòng simulated cũng tính, để kế hoạch thử có đúng nhịp mà live sẽ có.

loadKindStats (tiên nghiệm p̄_k, 6.2): thư gắn kết 90 ngày, bỏ 4 ngày gần nhất (lượt mở còn đang tới), bỏ thư của hai người duyệt (họ mở mọi thư mẫu), bỏ thư thử và bản sao.

6. Chấm điểm (score.ts, hàm thuần) ​

Không D1, không mạng, không Date.now(): mọi mốc thời gian vào qua tham số. Cùng dữ liệu cùng ngày luôn ra cùng một kế hoạch, nên bảng xem trước và lần chạy lại sau lỗi khớp nhau, và test kiểm được toàn bộ logic chọn mà không cần CSDL.

6.1 Tầng ​

last_engaged = muộn nhất của: lần cuối tương tác người thật với thư gắn kết (bấm, hoặc mở trọng số ≥ 0,6), lần vào web, hoạt động học, ngày tạo tài khoản (chỉ users), lần mở/bấm thư hệ cũ. streak = số thư gắn kết thật (không tính dòng ảo) LIỀN NHAU gần nhất không có tương tác.

dung  nếu streak ≥ 6 VÀ im > 30 ngày
moi   nếu tài khoản ≤ 14 ngày tuổi VÀ streak < 3
còn lại: bậc gốc theo số ngày im (≤14 nong, ≤45 am, ≤120 nguoi, >120 ngu),
         cộng floor(streak / 3) bậc, tối đa ngu

Cứ 3 thư liền không tương tác thì tụt một bậc: một lượt vào web hôm qua không được mua thêm hai tuần thư hằng ngày cho người đã ngừng đọc thư. Ngưỡng dừng 30 ngày, không 60: với 60, một người thôi tương tác vẫn nhận khoảng 33 thư trước khi hệ dừng (rà soát 14.10.2026).

TầngKhoảng cách tối thiểu giữa hai thư gắn kếtNgưỡng EV θLoại được phép
moi1 ngày0,05mọi loại
nong1 ngày0,08mọi loại
am2 ngày0,10mọi loại
nguoi4 ngày0,12mọi loại
ngu7 ngày0,15loại có dormantOk: pro_expiring, order_pending, learning_nudge, profile_incomplete, event_upcoming, session_upcoming, whats_new_weekly, reconnect, win_back
dungkhông gửin/akhông

Thư Quan hệ không phải chờ khoảng cách dài của tầng nguội (tối đa 2 ngày): hạn Pro còn 3 ngày không chờ được 7. Nhịp người nhận tự chọn ghi đè: weekly → khoảng cách ≥ 7 ngày (cả với thư Quan hệ); important → chỉ nhóm Quan hệ; none → như đã huỷ. So khoảng cách cho phép trễ 0,2 ngày (thư hôm qua 11:00 và hôm nay 08:00 vẫn là hai ngày khác nhau).

6.2 Xác suất tương tác p ​

Trọng số một thư đã gửi (lấy mức cao nhất): bấm người thật 1,0 · mở via_proxy = 1 (proxy Gmail, phần lớn là người thật) sau 60 giây 0,6 · mở via_proxy = 0 không phải UA máy quét Gmail (Edge/12.246, content-sampling) sau 60 giây (dấu Apple MPP: UA trần Mozilla/5.0) 0,15 · mở trong 60 giây đầu 0. Cột via_proxy ghi ngược với tên của nó, xem known-risks. Chỉ tính thư gắn kết: thư giao dịch bị CC cho người vận hành (EMAIL_AUDIT_COPY.mode='cc') nên lượt mở của người vận hành lẫn vào.

Giảm theo tuổi bằng bậc: ≤ 30 ngày ×1,0 · 31-90 ×0,6 · 91-365 ×0,3.

Tiên nghiệm theo loại co về trung bình toàn hệ (kindPriors):

p_all = e_toàn_hệ / n_toàn_hệ   nếu n_toàn_hệ ≥ 30, nếu không 0,13 (GLOBAL_DEFAULT_P, đo 14.10.2026)
p̄_k   = (e_k + 30 · p_all) / (n_k + 30), kẹp trong [0,02; 0,95]

Bản đầu dùng mặc định theo nhóm (0,40/0,30/0,20/0,15), cao gấp 2-4 lần thực tế: loại chưa đo luôn thắng loại đã đo, rồi tụt hẳn khi đủ 30 thư. Co về trung bình đo được thì loại mới bắt đầu ở mức thật của hệ và tiến dần về tỉ lệ của chính nó, không nhảy bậc.

Beta-Binomial theo người, độ mạnh tiên nghiệm m = 8:

a = m·p̄_k + e_uk + 0,5·(e_u − e_uk) + legacyE
b = m·(1 − p̄_k) + (n_uk − e_uk) + 0,5·((n_u − n_uk) − (e_u − e_uk)) + max(0, legacyN − legacyE)
p = a / (a + b)

e_u, n_u: tổng có trọng số của người đó trên mọi loại gắn kết; e_uk, n_uk: riêng loại k. Bằng chứng cùng loại tính đủ, khác loại tính một nửa. Người hệ cũ: từng bấm thư cũ legacyE = 1, từng mở 0,6; legacyN = 2 nếu nhận ≥ 2 mà chưa mở lần nào, 1 nếu từng nhận.

Khám phá: khi explore = on, p̃ rút từ Beta(a, b) (Thompson sampling) bằng bộ sinh có hạt giống hash(email_key|plan_date|kind|ref). p̃ chỉ dùng để xếp hạng giữa các loại của một người; quyết định gửi hay im và thứ tự dưới trần dùng trung bình p. Nếu không, một lần rút may mắn gửi thư cho người lẽ ra đang được để yên, và thứ tự ngày đầu thành ngẫu nhiên.

6.3 Giá trị v, điểm và cổng ​

v      = base_k × relevance × urgency × freshness
mỏi    = 0,75^(số thư gắn kết 7 ngày qua, gồm dòng ảo) × (0,7 nếu cùng loại với thư gần nhất)
xếp    = p̃ × v × mỏi      (chọn loại thắng cho người này)
gửi khi  p × v × mỏi ≥ θ(tầng)

Với mỏi 0,75^n và ngưỡng ở 6.1, một thành viên nóng điển hình nhận khoảng 3 thư/tuần, không phải 7.

Loạibase_kurgency / freshness
pro_expiring1,2urgency 1,3 khi còn ≤ 3 ngày
order_pending1,1
learning_nudge, reconnect, win_back1,0
session_upcoming0,95urgency 1,2 khi bắt đầu trong ≤ 3 ngày
event_upcoming0,9urgency 1,2 khi bắt đầu trong ≤ 3 ngày
weekly_progress, community_weekly0,8
journey_day0,8urgency 1,25 khi tài khoản ≤ 30 ngày tuổi; relevance luôn 1,0 (cộng đồng nhà)
whats_new_weekly0,75freshness 1 − 0,05/ngày kể từ mục mới nhất, sàn 0,5
course_new0,75freshness 1 − tuổi/60, sàn 0,5
profile_incomplete0,7
unit_story, pro_offer0,6
jtbd_case0,55
book_chapter, playbook_pick0,5playbook: freshness 1 nếu lên lịch trong 14 ngày, còn lại 0,8

relevance (RELEVANCE trong config.ts): cộng đồng nhà 1,0 · cộng đồng khác đã tham gia 0,85 · cộng đồng suy từ workshop hệ cũ 0,7 · nội dung toàn trường 0,7 · chung 0,45 · nội dung của cộng đồng người đó KHÔNG tham gia 0,2 (Bàn tròn Beauty cho người làm tài chính: dưới mọi ngưỡng trừ tầng mới) · việc của chính người đó (nhóm Quan hệ, reconnect, win_back) 1,0. Hai loại không gắn cộng đồng có hằng số riêng: whats_new_weekly 0,8, playbook_pick 0,6.

Cổng (chooseForMember, rồi kindGate cho từng ứng viên, theo thứ tự): đã huỷ · nhịp none · tầng dung · người hệ cũ khi legacy_policy = off mà chưa xác nhận · không ứng viên. Rồi từng ứng viên: loại bị tắt trong cài đặt · minor mà không phải Quan hệ · nhịp important mà không phải Quan hệ · nhóm bị tắt · Ưu đãi chưa đồng ý · reconnect cho người không thuộc diện · người hệ cũ chưa xác nhận mà loại không có legacyUnconfirmed · tầng ngu mà loại không có dormantOk · khoảng cách · cooldown của loại · số lần tối đa · ref đã gửi. Ở live, ứng viên của nhóm ngoài live_classes bị lọc TRƯỚC khi chọn.

"Người hệ cũ đã xác nhận" CHỈ khi reconnect_answer = 'yes', tức đã bấm nút "Có" (POST) trên trang xác nhận. Một lượt bấm vào bài đọc KHÔNG phải đồng ý: Luật BVDLCN đòi đồng ý theo mục đích, nêu kênh, nội dung, tần suất tối đa, và trang "Có" nêu đủ ba thứ đó.

Ứng viên thua (tối đa 3) ghi vào alternatives_json; reason ghi một dòng cho người duyệt đọc: tầng nong · p=0.21 (rút 0.25) · v=0.60 · mỏi=0.75 · EV=0.095 ≥ θ=0.08.

6.4 Trần ngày và tăng tải ​

Xếp hàng dưới trần (applyCap): theo TẦNG trước (người vừa tương tác lên đầu), rồi theo p × v × mỏi × trọng_số_nhóm (Quan hệ 1,3 · Bản tin, Giữ liên lạc 1,0 · Ưu đãi 0,8), hoà thì theo băm địa chỉ. Thư reconnect có trần riêng min(50, floor(25% trần)). Xếp thuần theo EV thì ngày đầu 140/150 khe rơi vào người hệ cũ ngủ sáu tháng (reconnect giá trị 1,0), đúng nhóm dễ bấm "Báo spam" nhất. Giữ cap dòng đầu là planned, phần còn lại over_cap.

cap(D) = min(cap_start, cap_max)                     nếu mode ≠ live, hoặc chưa có ngày live nào
       = max(1, floor(sent(L) × 0,8))                nếu ngày live gần nhất L có dòng deferred
       = cap(L)                                      nếu L không khoẻ
       = min(cap_max, ceil(cap(L) × (1 + ramp_pct/100)))   nếu L khoẻ
khoẻ = không deferred, gửi hỏng < 5% số đã thử, huỷ nhận < 3 người VÀ < 1% số đã gửi
huỷ nhận trong ngày = huỷ bằng link (email_preferences.source='link') + chọn "không nhận",
                      tắt nhóm hay trả lời "Không" (email_audience_prefs), theo ngày VN

Nếu daily_quota > 0: cap = min(cap, floor(daily_quota × 0,6) − đỉnh), với đỉnh là ngày đông nhất 30 ngày qua của thư KHÔNG thuộc Nhịp thư (plan_date IS NULL): phần còn lại của hạn mức chừa cho mã đăng nhập, biên nhận và nemo12.com.

Trần tính lại lúc gửi. Lúc lập kế hoạch (07:07 ngày D-1) thì ngày D-1 chưa gửi gì, nên chưa biết nó có khoẻ không; trần lúc đó chỉ là tạm tính. selectDue (lần gửi đầu của ngày D) tính lại trần từ ngày live ĐÃ XONG gần nhất, trừ số dòng đã đi ra (sending/sent/failed/deferred/unknown), và hạ phần vượt xuống over_cap (skip_reason = cap_at_dispatch) theo đúng thứ tự tầng rồi điểm.

Mặc định cap_start = 150, ramp_pct = 0, cap_max = 1300. ramp_pct = 0 vì "khoẻ" ở trên chỉ là phần hệ tự thấy: báo spam, bật lại cứng và Gmail hoãn thư đến sau và chưa có đường về. Ở preview trần luôn là cap_start (xem trước đúng ngày live đầu tiên); bảng kế hoạch vẫn ghi số người đủ điều kiện và số over_cap.

6.5 Giờ gửi ​

Dòng dưới trần được rải theo thứ tự ưu tiên: phần tư đầu 08:00, rồi 09:00, 10:00, 11:00 giờ VN (SEND_HOURS_VN). Một đợt 1.300 thư cùng lúc từ một miền mà đỉnh cũ chỉ 141 thư/ngày là đúng kiểu Gmail đáp bằng hoãn 4.7.28. Bước xem trước dời tiếp send_at của từng loại nếu cần (mục 9). Chọn giờ theo từng người để giai đoạn sau: dữ liệu giờ mở đang lệch hẳn về 08:00 vì 80% thư gửi lúc đó.

7. Ứng viên (generators/) ​

Mỗi generator đọc theo lô, trả Candidate { emailKey, kind, refId, relevance, urgency, freshness, data, communitySlug }, và có stillValid(env, item) chạy lại lúc gửi. Một generator ném lỗi không làm mất cả ngày: các loại khác vẫn chạy, lỗi vào stats_json.generatorErrors và hiện ở bảng kế hoạch. stillValid ném lỗi thì dòng KHÔNG gửi (thư mời tới sự kiện đã huỷ tệ hơn thư không tới). Mọi nội dung của trường khác (Inside School, Pitstop chạy chung CSDL) và của cộng đồng ẩn bị loại: school_id = 5c000000-0001-4000-8000-000000000001, communities.status = 'active'.

LoạiNguồn và điều kiện riêngref_id (chống lặp)Kiểm lại lúc gửi
pro_expiringcommunity_memberships Pro hết hạn trong 14 ngày; số ngày đếm theo lịch VN (tối thiểu 1)<community_id>:<ngày hết hạn>:14d hoặc …:3d (đọc được ref community_id trần của daily.ts)còn Pro, CÙNG ngày hết hạn, chưa hết hạn
order_pendingcommunity_orders pending 1-14 ngày, đặt từ ngày 1 tháng này, số tiền còn khớp checkAgainstPolicy; bỏ người có yêu cầu kích hoạt submitted/processing/needs_review/pending/approved/auto_approvedorder:<id>#<n> (≤ 2 lần, cách 3 ngày)đơn vẫn chờ, chưa Pro, chưa có yêu cầu kích hoạt, giá còn khớp
learning_nudgecourse_enrollments ⋈ courses cả hai active, im 3-30 ngày; hoạt động = muộn nhất của ghi danh đổi, mở mục bài, mở tổng quan unit (KHÔNG dùng learner_behavior_evidence: đó là lượt chuyển trang); mỗi người một khoáslug:ngày-hoạt-động-cuốikhoá vẫn mở, người học chưa quay lại
weekly_progressthứ Hai; tuần Chủ nhật tới thứ Bảy vừa qua; "bài" = DISTINCT concept_id trong course_material_progress, cộng unit đã mở tổng quan; ≥ 1 bàituần ISO của Chủ nhật hôm trướcn/a
profile_incompletetài khoản không có dòng community_memberships nào (kể cả cộng đồng ẩn); 3 lần cách 5 ngày, rồi mỗi 60 ngàyprofile:<ngày>vẫn chưa có cộng đồng
event_upcomingevents của Conan, is_active, không cancelled/draft, starts_at từ giờ gửi + 3 giờ tới hết D+10 (lọc theo giờ, không theo status: 34/35 sự kiện vẫn ghi upcoming dù đã qua); một sự kiện mỗi người: liên quan nhất rồi sớm nhất; bỏ sự kiện của nhà nếu đã nhận community_weekly 7 ngày quaevent:<id>còn hoạt động, chưa bắt đầu, cộng đồng còn mở
session_upcomingworkshops công khai của Conan; giờ bắt đầu = event_date + start_time theo giờ VN (thiếu giờ thì bỏ); relevance 0,85 nếu từng học một buổi đã diễn ra, 0,7 nếu có sở thích suy từ workshop, còn lại 0,45workshop:<id>còn công khai, chưa bắt đầu
whats_new_weeklytuần mới nhất có mục đăng trong 9 ngày; tài khoản thấy public + member (đúng như trang tokyo /whats-new), người hệ cũ chỉ public; tối đa 6 mục; link ngoài *.conan.school bị bỏweek_keytuần còn ít nhất một mục is_active
course_newcourses ⋈ legacy_courses (is_active) ⋈ communities của Conan, courses.created_at trong 30 ngày (legacy_courses.created_at rỗng ở 206/229 dòng); chỉ cộng đồng người đó đã tham gia; slug chuẩn COALESCE(l.slug, c.slug)course:<id>khoá vẫn mở
community_weeklythứ Hai; sự kiện 14 ngày của cộng đồng nhà + sự kiện toàn trường, tối đa 5tuần ISOmọi sự kiện trong thư còn hoạt động, ít nhất một chưa bắt đầu
unit_storyunit active có S/C/Q, khoá active, cộng đồng của khoá lấy từ legacy_courses.community_id; không đọc answer_vi (lời giải nằm sau cổng trả phí, thứ không rời CSDL thì không rơi vào thư)unit:<id>, không lặpunit còn trong kho VÀ link dựng lại bằng vị trí + slug hiện tại trùng link đã ghi
book_chapterlegacy_ebooks của Conan, đang mở, đã xuất bản, có chương 1 có tên và ý chínhbook:<id>, không lặpsách vẫn mở, slug chưa đổi
playbook_pickapi-cms GET /public/playbooks?locale=vi&limit=500 qua HTTP (từ 17.10.2026, generators/playbook.ts), published_at ≤ ngày gửi; CMS rỗng hay lỗi thì đường lùi D1 cũ legacy_seo_content_briefs approved/completed/in_progress, scheduled_date ≤ ngày gửi, slug như www cũ (slug ‖ slugify(title)), kèm log email_orch_playbook_source (CMS giai đoạn 4); bài mới nhất chưa nhận (không xoay vòng)playbook:<slug>, không lặp, chung hai nguồncòn bài công khai cho ra đúng slug, ở cùng nguồn
jtbd_casejtbd_sets is_free=1 + published + jtbd_jobs có câu Jobjtbd:<bộ>/<job>, không lặpbộ vẫn miễn phí, đã xuất bản, link dựng lại vẫn trùng
reconnectngười hệ cũ chưa trả lời, chưa huỷ; ấm (từng mở/bấm thư cũ) ≤ 2 lần cách 10 ngày; lạnh chỉ khi legacy_cold = once, 1 lần; nhắc tên workshop gần nhất của Conan đã đăng kýreconnect-<n>chưa trả lời, chưa huỷ ở id nào
win_backtài khoản im ≥ 45 ngày, < 2 lần, lần trước ≥ 60 ngày; tối đa 3 điều mới (What's New 60 ngày đăng sau lần cuối ghé + sự kiện 30 ngày của nhà hoặc toàn trường), lọc mục chào bán; không có gì mới thì không thưwinback-<n>không phiên web nào trong 45 ngày; sự kiện trong thư còn và chưa bắt đầu
pro_offer7 ngày cuối tháng; đồng ý ưu đãi + tài khoản hoạt động (học 30 ngày hoặc vào web 14 ngày) + mức Free; giá tháng sau > giá tháng này theo communityPrice; bỏ người có đơn chờ ở giá hiện hành<cộng đồng>:YYYY-MMvẫn Free, vẫn đồng ý, còn trong tháng của ref, giá hiện hành khớp TỪNG ĐỒNG với giá đã ghi, chưa có đơn chờ
journey_daytài khoản (segment = 'account') có cộng đồng nhà (homeCommunity), chưa nhận đủ 30 thư; mỗi người đúng một ứng viên: thư kế tiếp theo luật bên dưới; đọc theo lô khoá gần nhất, hồ sơ đủ bảy ô, số sự kiện sắp tới, ref đã giành ở email_sent_refsjourney:<N>, N = 1..30thư N-1 đã có trong email_sent_refs (thư 1 luôn qua)

Kho thường xanh của cộng đồng (unit_story, book_chapter, jtbd_case) đi qua các cộng đồng của người đó theo thứ tự nhà → đã tham gia → suy từ workshop, và chọn mục chưa gửi đầu tiên theo một thứ tự xoay riêng cho từng người (hash(email_key|kind) làm độ lệch): hai người cùng cộng đồng không nhận cùng một câu chuyện cùng ngày. Không có kho chung dự phòng: câu chuyện của một ngành ngẫu nhiên là tiếng ồn, người làm tài chính nhận chuyện quán cà phê đọc ra là thư rác. Chỉ đọc chữ dài của mục THẬT SỰ được chọn (IN theo lô ≤ 90 tham số).

Bảng link theo loại. Không đổi host theo người một cách máy móc: cùng một đường dẫn có thể có ở www mà không có ở tokyo. Người hệ cũ không có tài khoản tokyo, nên link của họ luôn về www.

LoạiTài khoản (account)Người hệ cũ (legacy)
event_upcomingtokyo /online-events/:slugwww /events/:slug
session_upcomingtokyo /workshops/:slugwww /workshops/:slug
whats_new_weeklytokyo /whats-new; link tương đối của mục public về www, mục member về tokyowww /whats-new; link tokyo bị bỏ
course_new, learning_nudge, weekly_progresstokyo /courses/:slugn/a (chỉ tài khoản)
order_pending, pro_offertokyo /c/:slug/pron/a
unit_storywww /courses/:slug/units/:n (n là VỊ TRÍ của unit đếm từ 1 theo sort_order, id, như UNIT_LIST_SQL của www; không phải sort_order)như cột trái
book_chapterwww /books/:slug/chapters/1 (tokyo không có route chương)như cột trái
playbook_pickwww /playbooks/:slugnhư cột trái
jtbd_casetokyo /c/:cộng-đồng/jtbd/:bộ/:jobwww /jtbd/:bộ/:job
win_backtokyo /whats-new, sự kiện tokyo /online-events/:slugn/a
journey_daytokyo /c/:slug và các tab /c/:slug/{courses,books,events,bmc,jtbd}, /courses/:slug (chỉ khi hồ sơ đủ), /onboarding, /communities (thư 16, 26)n/a (chỉ tài khoản có cộng đồng)

Không bao giờ dùng tokyo /events/:slug cho một sự kiện: đó là trang CHUỖI sự kiện, mở bằng slug sự kiện ra "Series không tồn tại" (lỗi có sẵn ở event_reminder, đã sửa cùng đợt này).

Chuỗi 30 thư làm quen (journey_day) ​

Ba file, tách theo người sửa: generators/journey.ts gom DỮ LIỆU THẬT, journey-days.ts giữ CHỮ của 30 thư (người soạn sửa chữ mà không đọc mã gửi thư), catalog-journey.ts dựng khung (tiêu đề, lời chào, nút, nhãn "Thư N/30", dòng xem trước, chú thích cuối thư). Nội dung chuyển từ PR #351 (đóng 14.10.2026), đã đối chiếu từng câu với production; xem known-risks.

Thư kế tiếp (nextJourneyDay): số NHỎ NHẤT trong 1..30 chưa có trong lịch sử của loại (sổ gửi 365 ngày + dòng kế hoạch chưa gửi của các ngày trước, tức lịch sử ảo ở mục 5) và chưa có trong email_sent_refs (đọc thêm vì sổ đó không có hạn một năm: người nhận thưa có thể đi chuỗi lâu hơn thế). Không phải "số thư đã nhận + 1": kế hoạch ngày mai lập lúc 07:07, TRƯỚC khi thư hôm nay đi, nên một thư N bị bỏ lúc gửi để lại sổ {1..N-1, N+1}; đếm thì ra N+1 lần nữa, đã gửi rồi, và chuỗi đứng im với already_sent_ref mỗi ngày mà không lỗi nào hiện ra. Dòng lịch sử không mang ref journey:N được coi là những thư đầu, nên chuỗi không bao giờ lùi về số nhỏ hơn số thư đã nhận.

Kiểm lại lúc gửi (stillValid): thư N chỉ đi khi email_sent_refs đã có journey:<N-1> của địa chỉ đó; thư 1 luôn qua. Thư N-1 bị bỏ thì thư N của kế hoạch ngày mai thành no_longer_valid, và lần lập kế hoạch sau chọn lại đúng N-1. Thiếu bước này, người nhận đọc thư 30 ("thư cuối") rồi mới tới thư 29.

Ai nhận. Chỉ tài khoản tokyo đã có cộng đồng nhà: mọi nút dẫn vào tokyo, nơi người hệ cũ không có tài khoản, và người chưa có cộng đồng là việc của profile_incomplete (đo 14.10.2026: 102/158 tài khoản chưa có cộng đồng nhà; bản viết cho họ ra 28 thư cùng một nút "Chọn cộng đồng"). Chuỗi bắt đầu từ thư 1 khi họ chọn xong. Ứng viên mang relevance 1,0, urgency 1,25 khi tài khoản ≤ 30 ngày tuổi, communitySlug là cộng đồng nhà (cổng gửi chọn alias nha theo đó, mẫu không tự trả from như bản PR).

Hợp đồng dữ liệu (JourneyData, mọi chuỗi là chữ THÔ, mẫu tự escape):

TrườngKiểuNghĩa
fullNamestring hoặc nullTên, qua tenGoi() ở mẫu
segment'account'Luôn là tài khoản
daynumberThư thứ mấy, 1..30
communitySlug, communityNamestring hoặc nullCộng đồng nhà
course{ title, slug, daysIdle, open } hoặc nullKhoá gần nhất người đó THẬT SỰ đụng tới (muộn nhất của ghi danh đổi trạng thái, mở mục bài, mở tổng quan unit, cùng định nghĩa với learning_nudge); chỉ khoá đang hiện ở danh mục (legacy_courses.is_active = 1), bỏ ghi danh completed/paused. daysIdle null = không biết lần học cuối. open = mọi bài mở cho người này (Pro còn hạn ở ĐÚNG cộng đồng của khoá, hoặc khoá không gắn cộng đồng gác phí); false thì họ chỉ đọc được bài đầu của unit đầu và tổng quan từng unit
pro{ slug, name } hoặc nullCộng đồng có Pro còn hạn, ưu tiên cộng đồng nhà
profileCompletebooleanĐủ bảy ô hồ sơ ở legacy_users (cùng luật resolveEntitlement); thiếu thì trang khoá, bài, sách ở tokyo hiện "Cần hoàn tất khai báo", nên thư dẫn vào tab của cộng đồng thay vì trang bị khoá
upcomingEventsnumber hoặc nullSố sự kiện sắp tới của cộng đồng nhà cộng sự kiện chung của trường; 0 thì thư sự kiện không hứa lịch và không dẫn tới tab Sự kiện rỗng
openedRecentlyboolean hoặc nullCó tương tác (bấm, hoặc mở qua proxy Gmail sau 60 giây) với ít nhất một trong ba thư chuỗi ĐÃ GỬI gần nhất; null khi chưa đủ ba thư. Chỉ dùng để gợi ý trang đổi nhịp, không nói "bạn chưa đọc"
memberDaysnumberSố ngày kể từ khi tạo tài khoản

Mẫu đọc phòng thủ từng trường (dữ liệu đi qua JSON của kế hoạch, và trang admin gửi thử bằng dữ liệu tay sửa), nghiêng về câu THẬN TRỌNG khi thiếu: khoá không rõ mở tới đâu thì coi như chưa mở trọn (không hứa "học tiếp"), hồ sơ không rõ thì coi như đủ, lịch không rõ thì nói có điều kiện.

Nhóm tuỳ chọn lam_quen. KIND_META.journey_day: nhóm Bản tin, category = 'lam_quen' ("Chuỗi thư làm quen", nhóm thứ năm trong MUTABLE_CATEGORIES và CATEGORY_OPTIONS), người gửi nha, base 0,8, cooldown 1 ngày, tối đa 30 thư cả đời, không legacyUnconfirmed, không dormantOk. Nhóm riêng để tắt chuỗi mà không mất nhắc học hay thư khoá mới. base 0,8 chứ không 0,65 (diễn tập 14.10.2026): với 0,65, thành viên không còn mới có EV ≈ 0,12 × 0,65 ≈ 0,078, dưới ngưỡng tầng nóng 0,08, nên chuỗi chỉ tới người vừa tạo tài khoản. Kế hoạch diễn tập cho ngày 16.10.2026 trên dữ liệu thật ra 7 thư chuỗi với 0,8, chỉ 2 với 0,65. Người đã nguội (θ từ 0,10) vẫn không nhận chuỗi cho tới khi tương tác lại.

Dấu nội dung cả chuỗi. Dấu vân tay (mục 9) chỉ dựng một dữ liệu mẫu mỗi loại mỗi ngày, tức MỘT thư của chuỗi; sửa thư 12 sau lúc xem trước thì dấu không đổi và thư đã sửa đi ra không ai duyệt. renderJourney gắn vào cuối HTML chú thích <!-- journey-content <16 ký tự hex> --> (người nhận không thấy): FNV-1a 32 bit hai hạt giống, băm đầu vào của layout (chủ đề, dòng xem trước, khối thân, nút) của cả 30 thư qua sáu biến thể dữ liệu, tính một lần mỗi isolate. Băm đầu vào chứ không băm HTML, vì khung có năm bản quyền: băm nó thì mỗi đầu năm dấu đổi và cả loại bị giữ một ngày. Sửa bất kỳ thư nào là đổi dấu vân tay, loại bị giữ tới lần xem trước sau. render là hàm thuần (không đọc giờ hệ thống), nên cùng dữ liệu luôn ra cùng chuỗi.

8. Lập kế hoạch (planner.ts) ​

orchestratorTick (cron mỗi giờ) tạo instance email-orch-plan-<D>-hHH cho ngày mai khi chế độ không phải off, giờ VN trong 07-20, và kế hoạch ngày mai chưa previewed. Lượt 07:07 thường làm xong; các lượt sau là lưới an toàn cho lần cron hụt (đã xảy ra: 25 giờ không cron nào chạy, 04-05.10.2026). Dừng ở 20:07: xem trước muộn hơn thì giờ gửi sớm nhất (xem trước + 24 giờ) đã quá 21:00 ngày D, và người duyệt nhận cả loạt thư mẫu cho một ngày không gửi được gì.

Workflow (action plan):

  1. lease: đọc cài đặt (off thì thoát); INSERT OR IGNORE INTO email_plan_runs(plan_date, 'planning', mode), rồi UPDATE … SET lease_until = now+50 phút, attempts = attempts+1 WHERE plan_date=? AND status IN ('planning','planned','failed') AND (lease_until IS NULL OR lease_until < now). changes = 0 → lượt khác đang giữ hoặc đã previewed, thoát. Đây là khoá CSDL.
  2. plan (chỉ khi run chưa planned; retries 2, timeout 10 phút; một bước, tính và ghi cùng nhau, vì kết quả bước giới hạn 1 MiB): cài đặt → audience → generators → kindPriors → chooseForMember → computeCap (+ phần hạn mức) → applyCap → rải giờ → ghi.
    • Ghi bằng INSERT … VALUES (…),(…) ON CONFLICT(plan_date, email_key) DO NOTHING, 5 dòng mỗi câu (19 cột, D1 cho 100 tham số), 40 câu mỗi batch. Không INSERT OR IGNORE: nó nuốt cả lỗi CHECK, một dòng mang trạng thái sai biến mất mà không ai biết.
    • Số đếm theo loại đọc lại từ BẢNG, không từ phép tính: lượt lập lại sau lỗi giữ nguyên dòng đã ghi.
    • email_plan_kinds: mỗi loại có người được chọn (trong HAY ngoài trần) một dòng mẫu, ưu tiên người có tên gọi rồi EV cao nhất; ghi đè chỉ khi preview_ok = 0. Ở preview, trần cắt phần lớn danh sách, mà người duyệt vẫn phải đọc được mọi loại sẽ có khi chạy thật.
    • email_plan_runs: status='planned', số đếm, cap, stats_json (theo loại, theo tầng, lý do không thư, lỗi generator). Hỏng sau 2 lần thử: markPlanFailed ghi error, thả khoá; lượt mỗi giờ sau lập lại.
  3. preview (retries 1, timeout 10 phút), mục 9.

Admin lập lại được một ngày (mục 13): resetPlan xoá dòng kế hoạch, loại, run của ngày đó, và từ chối khi ngày đó đã có dòng đi ra (sending/sent/failed/deferred/unknown). Xem trước cũng bị xoá, nên ngày đó phải chờ lại đủ 24 giờ.

9. Xem trước (preview.ts) ​

Với mỗi loại có planned > 0 hoặc over_cap > 0 mà chưa preview_ok:

  1. Giành loại (pha 1): UPDATE email_plan_kinds SET preview_claimed_at = now, preview_attempts = preview_attempts + 1 WHERE … AND preview_ok = 0 AND preview_attempts < 3 AND (preview_claimed_at IS NULL OR preview_claimed_at < now − 10 phút). changes = 0 → lượt khác đang gửi, hoặc đã hết 3 lượt thử.
  2. sendEmail tới từng địa chỉ trong REVIEWERS (hằng số trong mã, không nhận từ request hay cài đặt: một danh sách người nhận sửa được từ giao diện là một máy phát thư từ miền đã xác thực): cùng kind, sender, miền, data của dòng mẫu như thư thật; preview: { planDate, recipients, sendAtLabel }.
  3. Ghi (pha 2), chỉ khi MỌI hộp thư duyệt nhận được: preview_ok = 1, preview_at, fingerprint, sample_subject. Có lỗi thì ghi preview_error; lượt mỗi giờ sau thử lại. Bản đầu ghi preview_at TRƯỚC khi gửi, nên một bước chết giữa chừng làm loại đó không bao giờ được xem trước, cả loại im lặng cả ngày mà admin vẫn hiện "đã lập kế hoạch".
  4. Dời giờ của loại này: mọi dòng planned/over_cap có send_at sớm hơn bump (giờ tròn kế tiếp của lúc xem trước + 24 giờ) dời tới bump; dòng planned có send_at sau 21:00 ngày D thành skipped (preview_window).

Rồi gửi bảng kế hoạch (orch_preview_digest) một lần mỗi ngày (giành bằng email_plan_runs.preview_at IS NULL), riêng tới từng người duyệt: mỗi loại một hàng (người gửi, tiêu đề mẫu, địa chỉ mẫu đã che, số dòng, xem trước được chưa, lỗi, link "Giữ lại"), link "Giữ lại cả ngày", link tới admin. Run chuyển previewed khi không còn loại nào preview_ok = 0 mà còn lượt thử; nếu chưa thì giữ planned, và lượt mỗi giờ sau giành lại khoá, bỏ qua bước lập, chỉ chạy lại xem trước cho loại thiếu.

Dấu vân tay (fingerprintOf): SHA-256 của SEND_DECOR_VERSION, "<tên> <địa chỉ>" người gửi, và tiêu đề + HTML + chữ thuần của BA lần dựng: dữ liệu mẫu thật, cùng dữ liệu đó bỏ tên, và sample của danh mục; năm bản quyền thay bằng YYYY. Chỉ băm một lần dựng có tên thì đổi nhánh "không tên" của tiêu đề, đổi phần chữ thuần hay đổi người gửi đều lọt qua cổng mà không ai xem trước. Đổi phần trang trí của cổng gửi (chân thư, dải xem trước, viết lại link) thì tăng SEND_DECOR_VERSION. Một loại chứa nhiều thư khác nhau theo dữ liệu (journey_day, 30 thư) thì ba lần dựng chỉ chạm một thư; loại đó phải tự gắn dấu nội dung của mọi thư vào HTML (mục 7, "Dấu nội dung cả chuỗi"), nếu không sửa một thư khác sau lúc xem trước lọt qua cổng.

Thư mẫu ở cổng gửi: audit_of = 'preview:<D>' (thống kê lọc audit_of IS NULL nên không lẫn, và không bao giờ sinh bản sao), không user_id, không theo dõi bấm, đuôi tiêu đề · xem trước dd.mm (KHÔNG tiền tố [BẢN SAO →, phần đầu tiêu đề phải giống hệt thư thật), dải chữ ở đầu thư, chân thư và mọi link theo người nhận trỏ trang "đây là bản xem trước". Link theo người nhận không bao giờ được dựng sẵn trong data của kế hoạch: mẫu thư chỉ chứa chỗ giữ LINK_PLACEHOLDERS (https://api.conan.school/api/system/email/__recipient__/prefs, …/reconnect-yes, …/reconnect-no), và cổng gửi thay bằng link thật của người nhận hoặc bằng trang xem trước, ở cả HTML lẫn chữ thuần.

Giữ lại ​

Link trong bảng kế hoạch: GET /api/system/email/plan/hold?d&k&r&e&s, s = HMAC(EMAIL_UNSUB_SECRET, 'hold|d|k|r|e'), r là chỉ số người duyệt, e là hạn (23:00 giờ VN ngày D, nằm trong chuỗi ký nên không kéo dài được), k='*' là cả ngày. GET chỉ trả trang xác nhận có nút; POST mới ghi held_at, held_by (địa chỉ người duyệt thứ r), hold_reason='reviewer'. Thả giữ làm ở admin.

10. Gửi (dispatch.ts) ​

orchestratorTick, mọi lượt, mọi chế độ: sweepPast (ngày đã qua: sending → unknown, planned → expired; hôm nay: sending cũ hơn 30 phút → unknown). Chỉ dọn hôm nay lúc 22 giờ thì một lần cron hụt hay chế độ off để dòng treo mãi. Rồi:

  • live, run hôm nay previewed, không giữ cả ngày, còn dòng planned có send_at ≤ now → tạo instance email-orch-send-<D>-hHH.
  • preview → UPDATE … SET status='simulated' cho các dòng tới giờ, không tạo workflow.

Workflow (action dispatch):

  1. select (selectDue): tính lại trần (6.4), hạ phần vượt xuống over_cap, lấy tối đa min(300, chỗ còn lại) dòng planned tới giờ, theo tầng rồi điểm. 300 dòng giữ một instance dưới trần 10.000 lượt gọi con của Workflows với biên rộng (~12 lượt gọi mỗi dòng).
  2. send-N: lô 20 dòng, tuần tự trong lô (retries 1, timeout 5 phút). Tên bước luôn mới (send-0, send-1, ...): workflow trả kết quả đã lưu cho một tên bước đã chạy. Giữa hai lô có thư đi ra thì nghỉ 40 giây, tức khoảng 30 thư/phút.

Mỗi lô, trước khi giành dòng nào: đọc lại cài đặt (không live thì dừng, dòng ở lại planned: đọc cài đặt lỗi trả off, và một lần đọc hỏng không được biến dòng thành skipped vĩnh viễn); kiểm giữ cả ngày; tính trần mềm = floor(daily_quota × 0,8) hoặc 400 khi chưa biết hạn mức, so với số thư của CẢ api từ 00:00 UTC (hạn mức đặt lại lúc đó, tức 07:00 giờ VN; đếm cả bản sao và bản xem trước, chúng cũng tính vào hạn mức). Vì thế "Giữ lại" và "tắt" có hiệu lực từ lô kế tiếp, tối đa 20 thư.

Với mỗi dòng:

  1. Trần mềm đã chạm → dừng (soft_ceiling).
  2. Giành: UPDATE email_plan_items SET status='sending', attempt_at=now WHERE plan_date=? AND email_key=? AND status='planned'; changes = 0 → bỏ qua (instance khác đã lấy).
  3. Quá 22:00 ngày D → expired.
  4. Cổng loại (một lần mỗi lô cho mỗi (loại, send_at)): nhóm có trong live_classes (class_not_live) · có preview_ok, preview_at, fingerprint (preview_missing) · không bị giữ (held) · không stale_at (template_changed) · send_at ≥ preview_at + 24 giờ (preview_window) · fingerprint dựng lại bây giờ bằng fingerprint lúc xem trước. Khác thì markStale: đánh dấu loại hôm nay stale_at VÀ mở lại xem trước của loại đó cho NGÀY MAI (ngày mai đã được xem trước sáng nay bằng mẫu cũ; không mở lại thì loại đó mất hai ngày chứ không phải một), đưa run ngày mai về planned để lượt mỗi giờ gửi lại thư mẫu.
  5. Cổng người (đọc lại lúc gửi): đã huỷ ở bất kỳ id nào, trả lời "Không", nhịp none, important mà không phải Quan hệ, nhóm bị tắt (opted_out) · đã nhận thư nào hôm nay theo ĐỊA CHỈ (lower(recipient), audit_of IS NULL, status='sent', ngày VN, trừ thư người vận hành; chỉ thư sent: một lượt hỏng không tới ai, đếm nó là tự chặn chính lần thử lại) (already_mailed_today) · khoảng cách tới thư gắn kết trước kiểm lại bằng sổ gửi THẬT (gap).
  6. stillValid (mục 7) → no_longer_valid.
  7. Chốt chống lặp: INSERT INTO email_sent_refs … ON CONFLICT DO NOTHING; changes = 0 → already_sent_ref.
  8. sendEmail({ kind, to, userId, refId, data, sender, communitySlug, senderDomain, planDate, trackClicks: true }).
  9. Ghi kết quả:
Kết quảXử lý
oksent + delivery_id
E_DAILY_LIMIT_EXCEEDEDdòng → deferred, trả chốt lặp; dừng; bước defer-remaining chuyển mọi dòng planned của ngày thành deferred; bước trip-to-preview ghi mode = preview (updated_by = system:E_DAILY_LIMIT_EXCEEDED:<D>): hạn mức đã cạn nghĩa là mã đăng nhập cũng đang hỏng, ngày mai không được lặp lại
Trần mềmnhư trên nhưng lý do soft_ceiling, KHÔNG đổi chế độ
E_RATE_LIMIT_EXCEEDEDdòng về planned, trả chốt lặp (chưa gửi nên không trùng); workflow nghỉ 2 phút rồi chạy lại lô dưới tên bước mới (dòng đã gửi bị bỏ qua nhờ bước giành); 3 lần thì dừng, giờ sau nhặt tiếp
E_UNSUBSCRIBEDskipped (opted_out), trả chốt lặp
E_SENDER_*failed; giữ cả loại trong ngày (held_by='system', hold_reason = mã), các dòng sau của loại đó sender_error
mã khác (E_PREFS, E_RECIPIENT_SUPPRESSED...)failed với mã làm lý do, trả chốt lặp; sendEmail đã ghi email_preferences suppressed như cũ

Bất biến ​

#Bất biếnCơ chế
I1Một địa chỉ một thư gắn kết mỗi ngày trong Nhịp thưPK (plan_date, email_key)
I2Một dòng gửi nhiều nhất một lầnUPDATE … WHERE status='planned' có điều kiện; dòng kẹt thành unknown, không gửi lại
I3Một lượt lập kế hoạch tại một thời điểmlease trên email_plan_runs
I4Mỗi loại xem trước tối đa 3 lần mỗi ngày, không hai lượt cùng lúckhoá hai pha preview_claimed_at + preview_attempts
I5Không gửi loại chưa tới cả hai hộp duyệt ≥ 24 giờ, hoặc mẫu đã đổicổng loại, bước 4
I6Không cộng thêm vào ngày đã có thư nàocổng người, đọc sổ theo lower(recipient)
I7Một (địa chỉ, loại, mục) chỉ gửi một lần, kể cả khi kế hoạch hai ngày trùng nhauPK email_sent_refs
I8Trần dựa trên ngày đã xongselectDue tính lại trần lúc gửi
I9Thư gắn kết không tiêu hết hạn mức của thư tài khoảntrần mềm theo ngày UTC, phần hạn mức chừa lại

I6 là kiểm-rồi-gửi chứ không phải ràng buộc, vì thư giao dịch (mã đăng nhập) không được phép chờ khoá của thư gắn kết. Cửa sổ hở là vài trăm mili giây giữa kiểm và gửi.

11. Cổng gửi (send.ts) ​

Thêm vào SendEmailInput: sender?, communitySlug?, senderDomain?, preview?, planDate?, trackClicks?.

  • from = resolveSender(sender, communitySlug, senderDomain); replyTo: [email protected] khi From khác [email protected]; cột from_email, plan_date.
  • Mọi loại của Nhịp thư nằm trong ENGAGEMENT_KINDS (...NEW_ORCH_KINDS): chịu huỷ nhận, mang List-Unsubscribe, không bị CC. Quên một loại là thư đó CC cho người vận hành (địa chỉ hiện trong thư của người dùng) và bỏ qua huỷ nhận; test kiểm từng loại.
  • Header chỉ thêm cho thư gắn kết: X-Conan-Kind và hai header huỷ. Cloudflare từ chối CẢ lá thư nếu một header nằm ngoài danh sách cho phép, và Feedback-ID do Cloudflare tự đặt. Thư giao dịch giữ nguyên, không thêm header nào.
  • Loại thuộc nhóm Ưu đãi: tự gắn [QC] đầu tiêu đề nếu thiếu (Nghị định 91/2020, Điều 18).
  • prefsBlock: mọi thư gắn kết (kể cả của daily.ts) tôn trọng email_audience_prefs tại cổng: none hay trả lời "Không" → E_UNSUBSCRIBED; important mà không phải Quan hệ, nhóm bị tắt, hay weekly mà đã có thư gắn kết trong ~7 ngày → E_PREFS. Lỗi tra cứu thì cho gửi.
  • injectRecipientLinks thay chỗ giữ ở HTML và chữ thuần; chân thư gắn kết: "Đổi nhịp nhận thư · Huỷ nhận thư" (trang tuỳ chọn ký theo user_id, link huỷ ký user_id trần như cũ).
  • trackClicks (thư gắn kết, không phải thư mẫu): sau withUtm, mọi href="https://*.conan.school/..." không phải /api/ được viết lại thành /api/system/email/c/<deliveryId>?u=<base64url>&s=<HMAC('click|id|url')>. Không có secret thì giữ nguyên thư: thà mất số liệu bấm còn hơn gửi link hỏng.
  • Thư giao dịch thử lại MỘT lần sau 1,5 giây khi gặp E_RATE_LIMIT_EXCEEDED: lượt bị từ chối vì tốc độ là lượt chưa gửi, nên thử lại không sinh thư trùng. Thư gắn kết thì không, vòng gửi tự lùi.
  • orch_preview_digest nằm trong EMAIL_AUDIT_COPY.exclude (thư tới dac2207 không được CC sang dac2205) và trong REPORT_KINDS của report.ts (thư tổng kết không tự đếm nó).

12. Trang có chữ ký (domains/system/email-orch.ts) ​

Gắn ở /api/system/email. Luật chung: GET không bao giờ ghi (máy quét link của doanh nghiệp và Gmail tải trước mọi link trong thư); danh tính nằm ở chữ ký trong URL, không ở phiên, nên form POST lại đúng URL đó và không cần CSRF; mọi trang có frame-ancestors 'none', X-Frame-Options: DENY, Referrer-Policy: no-referrer, Cache-Control: no-store, không script.

ĐườngGETPOST
/c/:deliveryIdkiểm chữ ký + đích https://*.conan.school → ghi email_clicks (is_bot nếu HEAD, UA rỗng, UA máy quét, hoặc < 10 giây sau khi gửi) → 302. Mọi lỗi về trang chủ www, không bao giờ 500, không bao giờ cửa chuyển hướng mởn/a
/prefstrang chọn nhịp/nhóm/ưu đãi (?preview=1 là trang "bản xem trước")lưu email_audience_prefs (+ consent_version); none ghi huỷ cho MỌI id, nhịp khác gỡ huỷ do chính người đó chọn (không gỡ suppressed); người hệ cũ chưa "Có" được chỉ tới trang xác nhận thay vì hứa suông
/reconnecttrang xác nhận "Có" (nêu kênh, nội dung, nhịp tối đa) hoặc "Không"yes → ghi trả lời, nhịp none về auto, gỡ huỷ tự chọn; no → ghi trả lời + huỷ mọi id
/plan/holdtrang xác nhận (kiểm hình dạng, chữ ký, hạn)ghi giữ loại hoặc cả ngày
/unsubscribetrang xác nhận có nút + link "Đổi nhịp nhận thư"huỷ. Giữ đúng RFC 8058: u, t đọc từ QUERY, chấp nhận thân List-Unsubscribe=One-Click, không cookie, không Origin, trả 200 HTML không chuyển hướng; ghi hỏng thì 503 (Gmail coi 2xx là đã huỷ)
/__recipient__/*trang "bản xem trước": nếu một chỗ giữ nào lọt ra thư thật, người bấm thấy trang giải thích thay vì lỗi JSONn/a

So chữ ký bằng phép so thời gian hằng. Mục đích nằm trong chuỗi ký (prefs|, reconnect|, click|, hold|), nên chữ ký của mục đích này không dùng được cho mục đích khác; chữ ký huỷ cũ (ký user_id trần) giữ nguyên để link trong thư đã gửi vẫn chạy. Đổi câu chữ nhãn "Ưu đãi" hay danh sách "Bạn sẽ nhận" thì PHẢI tăng PREFS_CONSENT_VERSION / RECONNECT_CONSENT_VERSION: luật đòi bằng chứng người ta đồng ý VỚI CÁI GÌ, không chỉ lúc nào. Mọi trang dùng chung một khung (renderPage) và EMAIL_PALETTE.

13. Admin ​

/api/admin/emails/orchestrator, gắn TRƯỚC router emails.ts (router đó có GET /:id; gắn sau thì /emails/orchestrator rơi vào tra cứu lượt gửi có id "orchestrator"). Đã thuộc DOMAIN_BY_PATHusers: GET cần users.read, ghi cần users.write. Lỗi trả qua errorResponse().

ĐườngViệc
GET /settingscài đặt đã chuẩn hoá, dòng thô (ai sửa, lúc nào), công tắc daily_email_enabled của vòng cũ, siêu dữ liệu 18 loại, nhóm, người duyệt
PUT /settingskiểm MỌI khoá trên TRẠNG THÁI SAU KHI GHI rồi ghi trong MỘT batch: live cần daily_quota > 0; cap_max ≥ cap_start; chuyển legacy_policy sang open cần confirm: 'open'
POST /stop-allnút đỏ: Nhịp thư off VÀ platform_settings.daily_email_enabled = 'false' trong một batch (một giao dịch). platform_settings dùng ở đây chỉ vì daily.ts đọc công tắc của nó ở đó
POST /daily-email{ enabled }: bật/tắt lại vòng cũ; thiếu đường này thì nút đỏ là công tắc một chiều
GET /runslịch sử các ngày (tối đa 60) + đếm theo trạng thái
GET /plan/:daterun, mọi loại (kể cả loại 0 ứng viên), tiêu đề mẫu, người gửi, xem trước, giữ, lỗi generator, phân rã theo trạng thái/tầng/phân khúc/lý do bỏ qua
GET /plan/:date/itemsdanh sách dòng, lọc theo loại, trạng thái, chuỗi tìm; tối đa 200 một trang
GET /plan/:date/items/:emailKey/renderdựng lại đúng lá thư, KHÔNG gửi; link theo người nhận thay bằng trang xem trước (một link tuỳ chọn có chữ ký của người thật lọt ra màn hình admin là đổi được nhịp của người đó); tiêu đề có [QC] như thư thật
POST /plan/:date/hold{ kind, release? }, kind='*' là cả ngày; thả một loại xoá luôn hold_reason (loại bị hệ giữ vì sender_error thả ở đây)
POST /plan/:date/run{ replan? }: chỉ ngày mai tới +7 ngày, chế độ không off, không đang có lượt giữ khoá; ngày đã previewed cần replan, và resetPlan từ chối nếu đã có dòng đi ra
POST /samplesdữ liệu mẫu của 18 loại tới hai hộp duyệt như một lượt xem trước (ref_id='admin-samples'), khoá cả hệ 10 phút bằng KV; không đụng cổng 24 giờ

Trang Nhịp thư (admin.conan.school/community/email/orchestrator, cụm admin-trial, mục Member community → Email): bốn tab. Chế độ (công tắc, trần, hạn mức, nhóm gửi thật, miền gửi, người hệ cũ, loại bị tắt, nút "Dừng mọi thư gắn kết", bật lại vòng cũ); Kế hoạch (một ngày theo loại: số dòng theo trạng thái, người gửi, tiêu đề mẫu, xem trước, nút giữ/thả, lập lại, gửi bộ mẫu); Người nhận (từng dòng, lý do chọn, ứng viên thua, xem từng lá thư trong iframe sandbox); Lịch sử. Trang không giữ luật nào: hộp xác nhận chỉ để người bấm đọc hệ quả, mọi hàng rào nằm ở API. Icon Heroicons, màu token, shadcn.

14. Cài đặt ​

Bảng email_orch_settings, chỉ ghi qua PUT /settings (hoặc stop-all, và tripToPreview của hệ). Đọc lỗi thì readSettings trả mode = off, KHÔNG phải mặc định: một vòng gửi thư cho hàng nghìn người mà không đọc được công tắc của chính nó thì phải đứng yên. Khoá thiếu hay giá trị hỏng rơi về mặc định; cap_max < cap_start thì nâng cap_max.

KhoáMặc địnhGiá trịVì sao mặc định này
modepreviewoff, preview, liveKhông gửi ai cho tới khi người soạn tự bật
cap_start1501-5000Gần đỉnh cũ 141 thư/ngày của miền
cap_max13001-5000Cỡ toàn bộ vũ trụ người nhận
ramp_pct00-100Hệ chưa thấy báo spam, bật lại cứng, Gmail hoãn thư
legacy_policyreconnectreconnect, open, offNgười hệ cũ chưa từng đồng ý nhận thư của hệ mới
disabled_kindsrỗngdanh sách mã loại
exploreonon, offThompson chỉ xếp hạng, không quyết gửi
live_classesquan_hetập con của bốn nhómCloudflare ghi Email Service chỉ cho thư giao dịch (PRD Q1)
sender_domainconan.schoolconan.school, tin.conan.schoolMiền con phải được bật Email Sending trước
daily_quota0 (chưa biết)0-1.000.000Không đọc được bằng công cụ hiện có; live đòi > 0
legacy_coldskipskip, onceNgười chưa từng mở thư là nhóm dễ bấm "Báo spam" nhất

15. Hỏng mà vẫn trông như chạy, và cách hệ này lộ ra ​

HỏngLộ ra ở đâu
Cron hụt cả ngàyBảng kế hoạch không tới hộp thư; admin hiện ngày mai "chưa lập"; lượt mỗi giờ sau tự lập tới 20:07
Một generator ném lỗigeneratorErrors trong bảng kế hoạch và tab Kế hoạch; các loại khác vẫn chạy
Thư mẫu một loại không tới một hộp duyệtpreview_error ở bảng kế hoạch và admin; loại đó không gửi
Alias bị từ chốiLoại đó held_by=system, hold_reason=E_SENDER_*; các dòng sau sender_error
Hạn mức CloudflareDòng deferred, chế độ tự về preview (updated_by ghi lý do), trần lần sau tự hạ
Trần mềm khi chưa biết hạn mứcDòng deferred lý do soft_ceiling
Mẫu đổi sau xem trướcDòng template_changed, stale_at của loại, xem trước ngày mai gửi lại
Kế hoạch hai ngày trùng mụcalready_sent_ref (lẽ ra không bao giờ có: có là lịch sử ảo đang thiếu)
Kho ứng viên cạnBảng kế hoạch: "0 ứng viên" theo loại và số người không thư theo lý do
Người im lặng bị gửi mãiTầng dung đếm trong bảng kế hoạch
Dòng kẹt giữa chừngunknown, không gửi lại

16. Quan hệ với phần có sẵn ​

  • daily.ts: thoát sớm khi mode='live' (orchestratorOwnsDaily(); bảng chưa có thì trả false, vòng cũ giữ khe như trước), nên chỉ một vòng giữ khe. Nút đỏ tắt cả vòng cũ; POST /daily-email bật lại.
  • Ref của pro_expiring và learning_nudge đổi dạng (thêm kỳ hạn, thêm ngày hoạt động cuối), nhưng generator đọc được ref trần cũ của daily.ts để không nhắc lại thứ vòng cũ vừa nhắc. profile_incomplete thêm vòng 60 ngày: 92/101 người chưa có cộng đồng đã dùng hết ba lần cũ.
  • reminders.ts (event_reminder) giữ nguyên luồng: người đã đăng ký sự kiện được nhắc dù đã có thư khác. Link đổi sang /online-events/:slug.
  • report.ts: các loại mới tự xuất hiện trong thư tổng kết vì nó gộp theo kind; orch_preview_digest nằm trong REPORT_KINDS.
  • PR #351: đóng 14.10.2026, không merge. Nội dung 30 thư của nó vào Nhịp thư thành journey_day (mục 7, "Chuỗi 30 thư làm quen"); ý tưởng người gửi theo cộng đồng của nó đã vào từ trước thành alias nha.

Tài liệu nội bộ nền tảng Conan School.