Skip to content

Email của hệ thống ​

Conan School gửi email qua Cloudflare Email Service, và chỉ qua đó. Không Resend, không SendGrid, không SMTP riêng. Từ 04.09.2026 toàn bộ đường gửi cũ (Resend) đã gỡ; gói resend không còn trong package.json của api.

Nguồn sự thật về "có những email nào" là một file: api.conan.school/src/shared/email/templates.ts (EMAIL_CATALOG). Trang này viết từ file đó, thêm loại email mới thì thêm vào catalog trước, rồi mới cập nhật trang này.

Kiến trúc: một cổng, một sổ, một ảnh theo dõi ​

domain (user, events, admin…) ──► sendEmail()/fireEmail() ──► env.EMAIL.send() ──► hộp thư người dùng
                                   src/shared/email/send.ts                          │
                                          │                                          │ tải ảnh 1×1
                                          ├──► email_deliveries (D1), mỗi lượt gửi một dòng
                                          │                                          ▼
                                          ├──► bản sao kiểm tra (thư riêng) ──► GET /api/system/email/o/<id>.gif
                                          │                                          │
                                          └──────────────────────────────────────────┴──► email_opens (D1)
Thành phầnỞ đâuViệc của nó
Binding EMAILwrangler.toml → [[send_email]] name = "EMAIL"Cổng ra Email Service. Miền conan.school phải được bật Email Sending.
EMAIL_CATALOGsrc/shared/email/templates.ts12 loại email: tên, trigger, mục đích, dữ liệu mẫu, hàm dựng subject/html/text.
layout()cùng fileKhung dùng chung. Mọi email đi qua đây, đó là thứ làm giao diện đồng nhất.
sendEmail()src/shared/email/send.tsSinh deliveryId, chèn ảnh theo dõi, gửi, ghi sổ, gửi bản sao kiểm tra. Không ném lỗi.
fireEmail()cùng fileGửi và quên, cho biên nhận. Gọi qua c.executionCtx.waitUntil(...).
lookupRecipient()cùng fileTra email của một người, hỏi cả users lẫn legacy_users, xem cảnh báo bên dưới.
sendDueEventReminders()src/shared/email/reminders.tsEmail duy nhất chạy theo lịch (cron hằng giờ).
email_deliveriesmigration 20260904a + 20260907aSổ gửi. Cột audit_of phân biệt bản sao kiểm tra với thư thật.
email_opensmigration 20260907aMỗi lượt tải ảnh một dòng, "mở vào những thời điểm nào".

Người gửi luôn là Conan School <hello@conan.school>.

Tiêu đề có tên người nhận ​

15 trong 16 loại đưa tên gọi của người nhận lên tiêu đề. Ngoại lệ là login_code: mã 6 số phải đứng đầu vì đó là thứ duy nhất người ta cần thấy khi liếc qua hộp thư.

Tên đặt ở đầu tiêu đề, không phải giữa câu, Gmail trên điện thoại cắt tiêu đề ở khoảng 35 ký tự, nên tên nằm sau chữ thứ 35 là tên không ai thấy.

Tách tên gọi: dữ liệu trộn hai trật tự ​

tenGoi() trong templates.ts đoán trật tự tên trước khi cắt, vì bảng users chứa cả hai:

Tên đầy đủTrật tựTên gọi đúng
Đặng HồngViệt, họ trướcHồng (âm cuối)
Hien NguyenTây, tên trướcHien (âm đầu)

Phần lớn tài khoản đăng nhập bằng Google, và Google lưu theo trật tự phương Tây. Lấy âm cuối cho tất cả thì Hien Nguyen thành Nguyen, gọi người ta bằng họ, và vì Nguyễn là họ phổ biến nhất nên hàng chục lá thư cùng mở đầu bằng "Nguyen ơi". Lấy âm đầu cho tất cả thì Đặng Hồng thành Đặng, cũng là họ.

Quy tắc: nếu âm đầu là một họ người Việt thì lấy âm cuối, ngược lại lấy âm đầu. Danh sách họ trong HO_NGUOI_VIET, so sau khi bỏ dấu để Đặng và Dang khớp cùng một mục.

Đo trên 125 tên thật (09.09.2026): các tên gọi trùng nhiều nhất là Minh, Anh, Trung, Long, Hùng, đều là tên gọi thật. Nếu quy tắc sai thì đứng đầu danh sách đó đã là Nguyen, Tran, Le, đó là phép kiểm rẻ nhất khi sửa danh sách họ.

Luôn có tiêu đề dự phòng ​

tenGoi() trả null khi ô tên rỗng, hoặc khi chuỗi dài quá 20 ký tự (gần như chắc chắn là địa chỉ email hay tên tổ chức dán nhầm vào ô tên, đưa lên tiêu đề trông tệ hơn hẳn không có tên). Mọi mục dùng tieuDe(n, coTen, khongTen) nên bắt buộc khai bản dự phòng; không có chuyện tiêu đề ra "undefined ơi," hay mở đầu bằng dấu phẩy.

Giao diện đồng nhất: một khung, bảy khối ​

Email không có Tailwind, nên layout(title, blocks, opts) là chỗ duy nhất viết HTML. Không entry nào tự dựng thẻ <table> hay <div> riêng, thêm loại email là ghép các khối có sẵn:

KhốiDùng cho
pĐoạn văn.
bigMột chuỗi lớn, giãn chữ, hiện chỉ dùng cho mã đăng nhập.
badgeNhãn trạng thái trong thân thư. Bốn tông: ok, warn, neutral, brand.
rowsBảng khoá–giá trị: số tiền, ngày giờ, tên lớp.
buttonĐúng một hành động chính mỗi thư.
noteChú thích nhỏ, chữ nghiêng nhạt, cuối thư.
panelKhối giải thích: biểu tượng + tiêu đề chữ hoa + đoạn văn, kèm tuỳ chọn thẻ so sánh cạnh nhau (mức Free ↔ Pro).

opts có hai chỗ ngoài thân thư, và cả hai là phân loại, không phải trang trí:

  • eyebrow, nhãn NẰM TRÊN tiêu đề, trả lời "thư này về việc gì" trước khi người đọc đọc chữ nào.
  • status, nhãn ở góc phải hàng nhận diện, mang TRẠNG THÁI: Đã kích hoạt, Đang đối soát, Chờ duyệt, Còn 14 ngày.

Khung thư (dựng lại 16.09.2026) ​

Mọi thư đều có: nền ngoài canvas (be ấm), khung trắng bo 20px, hàng nhận diện ở đầu (ô logo đen bo góc + "Conan School" + "EDUCATION COMMUNITY" + nhãn trạng thái), eyebrow, h1 29px, thân, và chân trang hai dòng (liên kết portal + dòng bản quyền).

Bốn quyết định, tất cả đều có lý do kỹ thuật:

  1. Nền ngoài ấm. Thư trắng trên nền trắng của webmail không đọc ra là một tấm thiệp, nó tràn ra như một trang web. Nền canvas vẽ ra mép khung.
  2. Đầu thư không còn là thanh đen đặc. Thanh đen chiếm một phần sáu chiều cao thư mà không mang thông tin nào; giờ chỗ đó mang logo, tên, phân loại và trạng thái.
  3. Nhãn dùng nền NHẠT + chữ đậm cùng tông, không còn nền đặc chữ trắng. Nhãn nền đặc là một mảng màu bão hoà cạnh nút CTA, và nó lấy mất sự chú ý của nút.
  4. primary vẫn CHỈ dùng cho nút, xem luật ở đầu khối @theme trong theme.css. Nút là mảng màu bão hoà duy nhất của lá thư.

Trang xác nhận huỷ nhận thư (GET /api/system/email/unsubscribe) dùng cùng hàng nhận diện đó: nó là trang duy nhất người ta thấy sau khi bấm một liên kết trong thư, nên nó phải trông như thư.

Màu là hex chép từ packages/design-system/theme.css, mỗi dòng trong hằng C ghi tên token nguồn, ngoại lệ có ghi của luật màu, vì hộp thư không đọc được biến CSS. Thêm màu ở EMAIL_PALETTE thì thêm dòng vào manifest của scripts/check-token-sync.mjs, nếu không nó nằm ngoài tầm kiểm. Bản dựng lại này thêm bốn token vào theme.css: --color-canvas, --color-surface-subtle, --color-emerald-soft, --color-amber-soft.

Vì sao nền màu chỉ dùng cho khối ngắn ​

Nền ngoài dùng canvas, các mảng trong thân dùng subtle (rows, panel) hoặc accent (khối mã số, thẻ Pro). Nhưng đoạn văn vẫn nằm trên nền trắng, có lý do: chữ dài trên nền màu đọc mệt, và quan trọng hơn, một số client bỏ qua background của thẻ, khi đó chữ được tính màu cho nền màu lại rơi xuống nền mặc định và có thể thành chữ nhạt trên nền sáng.

Ba điều bắt buộc khi thêm khối có nền:

  • Đặt bgcolor="…" song song với background: trong style. Outlook trên Windows dựng bằng Word và bỏ qua background của thẻ; thiếu bgcolor là mọi mảng nền biến mất đúng ở client đó.
  • Không dùng hex 8 ký tự (#d94f2b22) và không rgba(). Nhiều client email không hiểu kênh alpha và bỏ cả khai báo. Cần màu nhạt hơn thì lấy một token ĐẶC khác (*-soft), đừng thêm alpha.
  • Không flex, không grid. Hai thẻ cạnh nhau trong panel là hai <td> của một hàng; Outlook dựng bằng Word và không có cả hai thứ kia.

Danh mục: 12 loại email ​

Tất cả trừ một loại là email giao dịch, gửi vì người nhận vừa làm một việc, hoặc hệ vừa làm một việc cho họ. Ngoại lệ duy nhất là event_reminder, chạy theo lịch.

Tài khoản và onboarding ​

LoạiGửi khiGiúp gì
welcome_memberTài khoản được tạo lần đầu, cả hai đường: mã email và Google OAuth.Thư đầu tiên. Giải thích mô hình cộng đồng và đẩy sang bước chọn cộng đồng.
login_codeNhập email ở màn đăng nhập → POST /api/user/auth/request-code.Cách duy nhất vào tài khoản. Mã 6 số, 10 phút, tối đa 5 mã/giờ. Gửi hỏng → API trả 503.
community_joinedChọn cộng đồng làm nhà → POST /api/communities/:slug/join. Chỉ lượt vào mới.Xác nhận đáp đúng chỗ; nói rõ free xem được gì, Pro mở thêm gì.

Kích hoạt membership (Master) ​

Bốn email theo vòng đời một yêu cầu kích hoạt. Người chuyển tiền không bao giờ rơi vào im lặng.

LoạiGửi khiGiúp gì
membership_claim_receivedGửi yêu cầu (POST /api/user/membership/claims).Trường đã nhận, máy đang đối soát. Người mua không gửi lại lần hai.
membership_needs_reviewWorkflow kết thúc needs_review.Máy chưa khớp, người sẽ xem trong 24 giờ, và nên làm gì.
membership_activatedactivateToMaster(), mọi đường kích hoạt; và admin duyệt tay.Biên nhận: đã là Master Member, số tiền, nguồn, nút vào học.
membership_claim_rejectedAdmin từ chối.Lý do và cách khắc phục.

Vì sao membership_activated nằm trong activateToMaster()

Trước 04.09 email chào mừng chỉ gửi ở webhook ngân hàng. Ba đường kích hoạt còn lại (khớp giao dịch trong workflow, OCR, mã 4 số) im lặng, người trả tiền được mở quyền mà không có biên nhận. Đặt email ở hàm duy nhất mọi đường đều gọi là cách duy nhất không quên đường nào.

Học tập, cộng đồng, sự kiện ​

LoạiGửi khiGiúp gì
community_pro_activatedAdmin duyệt đơn Pro.Biên nhận một năm Pro: cộng đồng, số tiền, ngày hết hạn.
class_enrolledPOST /api/learning/enroll.Có tên trong lớp, kèm trạng thái approved hay pending.
event_registeredPOST /api/events/:slug/register hoặc RSVP yes.Giữ chỗ: tên, giờ Việt Nam, địa điểm, nơi lấy link.
event_reminderCron hằng giờ, sự kiện bắt đầu trong 24 giờ tới, RSVP yes.Người đăng ký hai tuần trước đã quên. Email duy nhất kéo họ quay lại đúng giờ.
certificate_issuedAdmin cấp chứng chỉ.Chứng chỉ đã có, kèm link chia sẻ.

event_reminder chống gửi trùng bằng chính sổ gửi

Không có cột reminded_at, không có bảng cờ. Điều kiện NOT EXISTS (… email_deliveries …) trong reminders.ts là thứ duy nhất ngăn mỗi người nhận 24 lá thư giống hệt nhau, vì cron chạy mỗi giờ suốt 24 giờ trước sự kiện. Sửa truy vấn đó thì đọc kỹ điều kiện audit_of IS NULL: thiếu nó, bản sao kiểm tra bị tính là "đã nhắc".

Bản sao kiểm tra cho người vận hành ​

Mọi email gửi cho người dùng, trừ login_code và nhóm gắn kết, đều CC tới dac2205@gmail.com, cấu hình ở EMAIL_AUDIT_COPY trong templates.ts (mode: 'cc' từ 08.09.2026, theo yêu cầu người soạn).

Hai đánh đổi của CC thật, ghi ở đây để không ai ngạc nhiên:

  • Địa chỉ người vận hành hiện trong thư của mọi người nhận, và họ bấm "reply all" được.
  • Một thư = một ảnh theo dõi. email_opens không còn tách được lượt mở của người vận hành khỏi lượt mở của người dùng, nên tỉ lệ mở ở trang Email Catalog bị thổi lên. Đọc số ở đó kèm điều này.

Trước 08.09 hệ gửi một thư riêng (mode: 'separate') đúng vì hai lý do trên. Người soạn đọc cả hai rồi vẫn chọn CC, quay lại chỉ cần đổi một chữ.

Vì sao trước đây không phải CC thật (giữ lại làm hồ sơ quyết định):

  1. CC in địa chỉ người vận hành vào thư của mọi người dùng. Ai cũng đọc được, ai cũng "reply all" được. BCC không in ra, nhưng vẫn dính lý do 2.
  2. CC và BCC dùng chung một thư, nên chung một ảnh theo dõi. Người vận hành mở thư là email_opens ghi một lượt mở không tách được khỏi lượt mở của người dùng, phá đúng cái mà bảng đó sinh ra để trả lời.

Thư riêng có dòng email_deliveries riêng (audit_of = địa chỉ người nhận gốc), ảnh theo dõi riêng, và tiêu đề gắn tiền tố [BẢN SAO → người@nhận]. Muốn CC thật thì đổi mode thành 'cc' hoặc 'bcc'; 'off' tắt hẳn.

login_code không bao giờ có bản sao ​

EMAIL_AUDIT_COPY.exclude liệt kê các loại email không sinh bản sao dù mode là gì. Hiện có đúng một loại: login_code.

Lý do không phải là gọn gàng mà là an toàn. Bản sao của login_code mang mã đăng nhập 6 số của một thành viên khác, còn hiệu lực 10 phút, ai đọc được hộp thư người vận hành đăng nhập được vào tài khoản bất kỳ. Đó là một lỗ hổng leo thang đặc quyền, không phải một tiện ích kiểm tra.

Cả hai đường sinh bản sao (thư riêng và CC/BCC thật) hỏi chung một hàm wantsAuditCopy() trong send.ts. Tách ra là cố ý: nếu mỗi đường tự kiểm, một ngày nào đó đổi mode sang 'cc' sẽ làm mã đăng nhập chảy ra qua đường còn lại.

Cần xem login_code trông thế nào thì gửi thử cho chính mình, mã trong đó là mã giả:

bash
curl -X POST https://api.conan.school/api/system/email/smoke \
  -H "content-type: application/json" -H "x-email-smoke-token: $EMAIL_SMOKE_TOKEN" \
  -d '{"to":"ban@example.com","kinds":["login_code"]}'

Ai đã mở, lúc nào ​

Mỗi thư mang một ảnh 1×1 ở cuối thân, trỏ về GET /api/system/email/o/<deliveryId>.gif. Endpoint đó công khai (bắt buộc, hộp thư người nhận tải nó, không phải trình duyệt đã đăng nhập), trả GIF với Cache-Control: no-store, và ghi một dòng mỗi lượt tải vào email_opens. Id không có thật thì vẫn trả ảnh nhưng không ghi gì, nếu không, ai cũng bơm được dòng rác vào bảng.

bash
# Tổng hợp: mỗi lượt gửi, số lần mở, lần đầu, lần cuối
curl -s "https://api.conan.school/api/system/email/opens?email=nguoi@example.com" \
  -H "x-email-smoke-token: $EMAIL_SMOKE_TOKEN" | jq

# Toàn bộ mốc thời gian mở của MỘT lượt gửi
curl -s "https://api.conan.school/api/system/email/opens/<deliveryId>" \
  -H "x-email-smoke-token: $EMAIL_SMOKE_TOKEN" | jq

Con số này là cận DƯỚI, và có nhiễu

Ảnh theo dõi là cách đo duy nhất khả thi qua email, nhưng nó sai theo cả hai chiều:

  • Thiếu. Client chặn ảnh (mặc định ở nhiều nơi) → người đọc thật mà không có dòng nào.
  • Thiếu. Gmail tải ảnh qua proxy rồi cache → thường chỉ ghi được lượt mở đầu tiên, các lần mở sau không chạm tới server dù đã đặt no-store.
  • Thừa. Apple Mail Privacy Protection tải trước mọi ảnh dù người dùng không mở thư → dòng "mở" giả, thường trong vài giây sau khi gửi.

Cột via_proxy đánh dấu các lượt đến từ proxy đã biết. Đừng báo cáo "tỉ lệ mở" như một sự thật; hãy đọc nó như "ít nhất bao nhiêu người đã mở".

Nhịp một-thư-mỗi-ngày ​

Mục tiêu người soạn đặt (07.09.2026): mỗi thành viên nhận tối thiểu một email mỗi ngày; loại khẩn cấp được vượt số đó.

Cron 0 1 * * * (08:00 giờ Việt Nam) chạy shared/email/daily.ts. Với từng thành viên đủ điều kiện, nó chọn đúng một thư gắn kết hợp cảnh nhất, theo thứ tự ưu tiên:

#LoạiChọn khi
1pro_expiringSuất Pro còn ≤ 14 ngày. Mỗi suất nhắc một lần.
2learning_nudgeCó khoá đang active mà im lặng ≥ 3 ngày.
3profile_incompleteChưa chọn cộng đồng nào. Tối đa 3 lần.
4community_weeklyThứ Hai, và cộng đồng nhà có sự kiện trong 14 ngày tới.

community_weekly chưa gửi được lần nào, và lý do không nằm ở mã

Sự kiện đã được gắn cộng đồng (migration 20260908a): 13 buổi Conan AI Day vào AI / Technology, 7 buổi Conan Viral Day vào Marketing. Tám buổi còn lại, Saturday Lunch và Acoustic Night, cố ý để trống vì chúng không thuộc chủ đề nghiệp vụ nào.

Migration 20260908b gieo thêm bảy buổi Bàn tròn cộng đồng sắp diễn ra, mỗi cộng đồng có thành viên thật một buổi, từ 24.09.2026 trở đi. Từ đây nhánh này chạy được.

Vì sao bảy cộng đồng đó: chỉ mười tài khoản thật có cộng đồng nhà, và chúng nằm ở đúng bảy cộng đồng ấy. Đáng chú ý là AI / Technology có 13 buổi Conan AI Day nhưng không có thành viên nào, nối dài chuỗi đó sẽ không tới tay ai. Với email, "nơi có nội dung" và "nơi có người" là hai câu hỏi khác nhau, và câu thứ hai mới quyết định thư có tới ai không.

Bảy buổi này là buổi gieo sẵn, ban tổ chức phải xác nhận. Chúng hiện ra và đăng ký được ngay. Không có dòng event_meetings nào được tạo: không có link phòng họp thật để điền, và bịa một link là gửi người ta tới một phòng trống.

Không hợp cảnh nào thì không gửi gì. Gửi một lá thư rỗng cho đủ chỉ tiêu là cách tự tay dạy người ta bỏ qua thư của mình.

Ba luật của vòng gửi này ​

"Tối thiểu một" là SÀN, không phải hạn mức cộng dồn. Ai đã nhận bất kỳ thư nào trong ngày, kể cả thư giao dịch, thì hôm nay coi như xong. Không có luật này thì người vừa mua gói nhận biên nhận rồi nhận thêm một thư nhắc học trong cùng buổi sáng.

Chỉ gửi cho tài khoản thật. Nguồn là users với status='active' (121 dòng), không phảilegacy_users (1256 dòng, phần lớn là dữ liệu nhập chưa từng đăng nhập). Gửi hằng ngày vào một danh sách như vậy là cách nhanh nhất để miền bị đánh dấu spam, và khi miền mất uy tín thì thư giao dịch, gồm cả mã đăng nhập, cũng ngừng tới nơi.

Có công tắc tắt. platform_settings khoá daily_email_enabled; đặt 'false' là dừng ngay, không cần deploy:

bash
wrangler d1 execute conan-platform-production --remote --command \
  "INSERT INTO platform_settings(key,value) VALUES('daily_email_enabled','false')
     ON CONFLICT(key) DO UPDATE SET value='false'"

Hai nhóm email, và vì sao phải tách ​

ENGAGEMENT_KINDS trong templates.ts liệt kê nhóm gắn kết: learning_nudge, community_weekly, pro_expiring, profile_incomplete. Mọi loại còn lại là giao dịch.

Gắn kếtGiao dịch
Chịu tuỳ chọn từ chối nhận thư✅❌ không bao giờ
Chịu nhịp một-thư-mỗi-ngày✅❌ gửi ngay khi có việc
Có link huỷ nhận thư + header List-Unsubscribe✅❌
Sinh bản sao cho người vận hành❌ (số lượng lớn)✅

Đừng chặn thư giao dịch

Chặn login_code là khoá người ta khỏi tài khoản của chính họ. Chặn membership_activated là lấy mất biên nhận của người vừa trả tiền. Thêm loại email mới mà quên khai vào ENGAGEMENT_KINDS thì nó mặc định là giao dịch, an toàn cho người dùng, nhưng nếu nó thực sự là thư tiếp thị thì nó lọt qua cả tuỳ chọn từ chối lẫn nhịp mỗi ngày.

Địa chỉ bị chặn vĩnh viễn ​

Cloudflare giữ danh sách chặn riêng: địa chỉ từng bật lại cứng, từng báo spam. Wrangler không có lệnh nào liệt kê danh sách đó, nên ta chỉ biết một địa chỉ bị chặn KHI gửi hỏng với mã E_RECIPIENT_SUPPRESSED.

Không ghi lại thì vòng gửi hằng ngày thử lại đúng địa chỉ đó mỗi ngày, mãi mãi, và mỗi lần thử là một lượt gửi hỏng nữa tính vào hồ sơ của miền. Từ 09.09.2026 sendEmail tự ghi vào email_preferences với source='suppressed' khi gặp mã đó.

Cột source phân biệt hai loại trông giống nhau nhưng khác hẳn ý nghĩa:

sourceNghĩa
linkNgười chọn thôi nhận thư
suppressedĐịa chỉ không nhận được thư

Chỉ thư gắn kết bị chặn. Thư giao dịch vẫn thử và vẫn hỏng, đúng như vậy: nếu người đó bấm đăng nhập thì lượt gửi hỏng phải được ghi lại, không được im lặng bỏ qua.

bash
wrangler d1 execute conan-platform-production --remote --command \
  "SELECT source, COUNT(*) FROM email_preferences GROUP BY source"

Huỷ nhận thư ​

Thư gắn kết mang dòng huỷ ở chân và cặp header List-Unsubscribe / List-Unsubscribe-Post. Endpoint GET|POST /api/system/email/unsubscribe?u=<user_id>&t=<chữ ký> công khai, không cần đăng nhập, người muốn thôi nhận thư không nên bị bắt nhớ mật khẩu, và Gmail tự gọi nó ở chế độ huỷ-một-chạm. Chữ ký là HMAC của user_id với bí mật EMAIL_UNSUB_SECRET; thiếu chữ ký thì ai đoán được id là huỷ được của người khác.

Không có EMAIL_UNSUB_SECRET thì đừng bật vòng gửi hằng ngày

Thiếu bí mật, thư gắn kết đi ra không có link huỷ và không có header List-Unsubscribe. Gmail yêu cầu huỷ-một-chạm với người gửi số lượng lớn; thiếu nó thì người nhận bấm "báo spam" thay vì "huỷ nhận", và tỉ lệ báo spam làm hỏng khả năng giao thư của cả miền, kể cả mã đăng nhập.

Ảnh đại diện người gửi: ba nguồn, và BIMI là nguồn YẾU NHẤT ​

Gmail chọn ảnh cạnh tên người gửi theo thứ tự ưu tiên sau. Biết thứ tự này trước khi động vào BIMI sẽ tiết kiệm rất nhiều thời gian:

#NguồnAi đổi được
1Ảnh liên hệ của chính người nhậnChỉ người nhận. Phía gửi không đè lên được.
2Ảnh hồ sơ Google của tài khoản gửiQuản trị viên Workspace, hoặc chính tài khoản đó.
3BIMI (bản ghi DNS + chứng thư)Người có quyền DNS, và phải mua chứng thư.

conan.school dùng Google Workspace, MX trỏ aspmx.l.google.com, nên hello@conan.school là một tài khoản Google có ảnh hồ sơ riêng. Ảnh đang hiện trong hộp thư thật là nguồn số 2.

Không có dòng mã nào trong repo đổi được ảnh này

Ghi ngày 08.09.2026: người soạn thấy ảnh một người cạnh hello@conan.school và muốn đổi thành chữ C nền cam. Việc đó làm ở tài khoản Google, không phải trong mã. Dựng logo BIMI (đã làm 07.09) cũng không đổi được, vì BIMI đứng SAU ảnh hồ sơ trong thứ tự trên và còn cần chứng thư mất phí. Ai đi thẳng vào BIMI trước sẽ mất một ngày rồi mới phát hiện điều này.

Cách đổi: đăng nhập hello@conan.school, bấm ảnh đại diện góc trên phải, đổi ảnh. Hoặc quản trị viên vào Google Admin → Directory → Users → hello → thay ảnh. Google chỉ nhận PNG/JPG, không nhận SVG. File sẵn dùng: packages/design-system/brand/conan-avatar-512.png, chữ C trắng trên nền thương hiệu, cùng hình với logo BIMI.

Nếu vẫn muốn làm BIMI ​

Ảnh tròn hiện cạnh tên người gửi trong hộp thư không đặt được từ trong email. Không thẻ nào, không header nào làm được. Cách duy nhất là BIMI: hộp thư người nhận đọc một bản ghi DNS, tải một file SVG về, rồi mới hiện.

Ba điều kiện, ta đã có hai ​

Điều kiệnTrạng thái
DMARC ở mức thực thi (p=quarantine hoặc p=reject)✅ đã có p=reject
Logo SVG đúng chuẩn, phục vụ qua HTTPS✅ https://api.conan.school/brand/bimi-logo.svg
Bản ghi DNS default._bimi.conan.school❌ chưa có, cần người có quyền DNS thêm
Chứng thư VMC hoặc CMC❌ chưa có, Gmail bắt buộc, và mất phí

Bản ghi DNS cần thêm ​

Tên:  default._bimi.conan.school
Loại: TXT
Giá trị: v=BIMI1; l=https://api.conan.school/brand/bimi-logo.svg; a=

a= để trống là hợp lệ về cú pháp và đủ cho các hộp thư không đòi chứng thư. Gmail thì đòi, nên với Gmail phải điền URL của chứng thư vào a= sau khi mua.

Token OAuth của wrangler trong repo này chỉ có zone (read), nên agent không tự thêm bản ghi này được, phải làm trong bảng điều khiển Cloudflare.

Chứng thư: chỗ tốn tiền ​

Gmail chỉ hiện logo khi có chứng thư khẳng định quyền sở hữu hình:

  • VMC (Verified Mark Certificate), cần nhãn hiệu đã đăng ký. Cho thêm dấu tích xanh.
  • CMC (Common Mark Certificate, Google mở từ 2025), không cần đăng ký nhãn hiệu, chỉ cần chứng minh đã dùng logo ít nhất một năm. Rẻ hơn, nhưng không có dấu tích xanh và không được Apple Mail hỗ trợ.

Chưa có chứng thư thì bản ghi BIMI vẫn nên thêm: một số hộp thư hiện logo mà không đòi chứng thư, và khi mua chứng thư sau này chỉ cần điền vào a=.

api.conan.school/src/shared/brand.ts giữ SVG dưới dạng chuỗi, phục vụ ở /brand/bimi-logo.svg. Nó không phải SVG thường mà là SVG Tiny Portable/Secure, bộ xác thực từ chối file sai chuẩn, và từ chối im lặng, người gửi không nhận báo lỗi nào. Ràng buộc: version="1.2", baseProfile="tiny-ps", <title> là con đầu tiên, khung vuông, không x/y ở thẻ gốc, nền đặc (Gmail bo tròn ảnh, nền trong suốt thành mảng đen), không script, không thẻ <a>, không tham chiếu ngoài.

Chữ "C" vẽ bằng <path>, không dùng <text>: bộ xác thực không có font của mình nên <text> ra hình khác nhau ở mỗi nơi hoặc bị từ chối thẳng. Hình chỉ có một chữ cái trên nền đặc vì nó hiện ở khoảng 20px trong danh sách thư, chi tiết mảnh biến mất ở cỡ đó.

Đổi đường dẫn logo là gãy ảnh đại diện

Bản ghi DNS trỏ thẳng vào /brand/bimi-logo.svg. Đổi route hay đổi tên file mà quên sửa DNS thì hộp thư tải về 404 và bỏ ảnh, không có gì báo lỗi. Đổi DNS trước, đổi route sau.

Xem trong admin ​

admin.conan.school → Member community → Email, hai trang:

TrangĐường dẫnTrả lời câu gì
Email Logs/community/emailĐã gửi gì cho ai, có tới không, ai mở vào những lúc nào.
Email Catalog/community/email/catalogHệ có những loại email nào, gửi lúc nào, để làm gì.

Email Logs lọc theo loại, theo trạng thái và theo địa chỉ nhận; bấm một dòng để mở danh sách đầy đủ mốc thời gian mở, kèm cờ proxy cho lượt đến từ proxy ảnh. Ba ô tổng ở đầu trang đếm 30 ngày gần nhất.

Email Catalog đọc thẳng EMAIL_CATALOG qua API chứ không chép tay, nên không bao giờ lệch với thứ hệ thực sự gửi. Cột "có bản sao / không bản sao" phản ánh EMAIL_AUDIT_COPY.exclude.

Mỗi thẻ còn có cột lượt mở 30 ngày, không tính bản sao: dòng trên là số thư có ít nhất một lượt mở trên tổng số thư gửi thành công, dòng dưới là tổng số lần ảnh theo dõi được tải. Một người mở ba lần thì dòng trên tăng một, dòng dưới tăng ba, đừng trộn hai con số.

Cột đếm trong /stats phải là COUNT(DISTINCT d.id)

Truy vấn có LEFT JOIN email_opens, nên một lượt gửi được mở ba lần thành ba dòng và SUM(CASE …) đếm nó ba lần. Bản đầu dùng SUM cho failed và ra số đúng, nhưng đúng do may, vì thư gửi hỏng thì không ai mở được nên nó chỉ có một dòng. Endpoint ảnh chỉ kiểm lượt gửi có tồn tại, không kiểm nó đã gửi được hay chưa, nên chỉ cần một dòng email_opens trỏ vào một lượt gửi hỏng là con số sai thầm lặng.

Bản sao bị ẩn mặc định trong Email Logs

Bản sao kiểm tra là thư gửi cho người vận hành, không phải thư của người dùng. Trộn vào là nhân đôi số thư đã gửi và bóp méo tỉ lệ mở, nên mọi truy vấn ở đây lọc audit_of IS NULL trừ khi bạn bấm nút hiện chúng lên.

Gửi thử có ở hai chỗ. Trong Email Catalog, mỗi thẻ có nút “Gửi thử cho tôi”. Trong Email Logs, nút “Gửi thử loại này cho tôi” nằm trong khối chi tiết hiện ra khi bấm một dòng.

Vị trí nút ở Email Logs là chủ ý, không phải bố cục: nó nằm cạnh phần mô tả loại email, sau một bước bấm mở, chứ không nằm trên dòng danh sách cạnh địa chỉ người dùng. Một nút đặt cạnh địa chỉ người dùng, trên một trang toàn thư thật, sẽ có ngày bị đọc thành “gửi lại cho người này”. Câu xác nhận cũng nói thẳng: đã gửi tới hộp thư của bạn, không gửi lại cho người nhận gốc.

Gửi thử và gửi lại là hai chuyện khác nhau

Gửi thử dùng dữ liệu mẫu và người nhận là chính admin, không ai khác thấy gì, và mã đăng nhập trong thư mẫu là mã giả. Gửi lại một lượt gửi thật cho người dùng thật thì không có, và đừng thêm: đó là cách nhanh nhất để một biên nhận đi ra hai lần mà không ai truy được vì sao.

Nút gửi thử không có ô nhập địa chỉ, và đó là hàng rào chứ không phải sự lười. API lấy người nhận từ danh tính Cloudflare Access. Cho nhập địa chỉ tuỳ ý là biến trang admin thành máy phát thư từ một miền đã xác thực; một tài khoản admin bị chiếm sẽ gửi thư mạo danh Conan School đi khắp nơi, và uy tín gửi của miền mất thì rất lâu mới lấy lại được. Cần cho người khác xem thì chuyển tiếp thư.

Thư gửi thử ghi ref_id = 'admin-test' trong sổ, nên lọc ra khỏi thư thật được. Có khoá 15 giây mỗi người để một lượt bấm không thành mười hai lá thư.

API sau lưng hai trang là GET /api/admin/emails, /stats, /catalog, /:id và POST /test, đường ghi duy nhất là gửi thử. Không có đường xoá: sổ gửi là dấu vết, xoá được thì nó không còn là dấu vết. Router khai users trong DOMAIN_BY_PATH, nên đọc cần users.read, gửi thử cần users.write.

Gửi thử toàn bộ danh mục ​

bash
curl -X POST https://api.conan.school/api/system/email/smoke \
  -H "content-type: application/json" \
  -H "x-email-smoke-token: $EMAIL_SMOKE_TOKEN" \
  -d '{"to":"ban@example.com"}'
  • Gửi tối đa 10 email một lượt với dữ liệu mẫu. Danh mục có 12 loại, nên không truyền kinds thì 2 loại cuối bị cắt, truyền "kinds":[…] để chọn.
  • Mỗi địa chỉ một lượt / 10 phút (khoá KV).
  • Gửi thử tới chính địa chỉ bản sao thì không sinh bản sao (tránh nhân đôi).

Vận hành ​

Miền gửi. conan.school bật Email Sending ngày 04.09.2026. Kiểm: wrangler email sending list conan.school, wrangler email sending dns get conan.school.

Sổ gửi và sổ mở:

bash
wrangler d1 execute conan-platform-production --remote --command \
  "SELECT d.created_at, d.kind, d.recipient, d.status, COUNT(o.id) opens
     FROM email_deliveries d LEFT JOIN email_opens o ON o.delivery_id = d.id
    WHERE d.audit_of IS NULL GROUP BY d.id ORDER BY d.created_at DESC LIMIT 30"

Giới hạn của Email Service (beta, Workers Paid): 50 người nhận/thư, 5 MiB/thư, 32 tệp đính kèm. Lỗi thường gặp: E_SENDER_NOT_VERIFIED (miền chưa bật), E_RATE_LIMIT_EXCEEDED.

Chi phí một lượt gửi là hai lượt gửi. Bản sao kiểm tra nhân đôi số thư ra khỏi hệ. Ở quy mô hiện tại không đáng kể; khi danh sách người dùng lớn lên, cân nhắc mode: 'off' cho các loại có lượng lớn.

Rủi ro đã ghi ​

  • lookupRecipient từng chỉ hỏi một bảng. users và legacy_users là hai bảng khác nhau; 32/121 người có ở bảng này mà không có ở bảng kia. Xem known-risks.
  • Đăng ký bằng mã email từng là mã chết. Xem known-risks.
  • fireEmail nuốt lỗi theo thiết kế, binding hỏng chỉ hiện ở email_deliveries.status='failed'.
  • DMARC p=reject do Cloudflare tự thêm khi bật miền, áp cho cả thư Google Workspace. Xem known-risks.

Đo thật: thư profile_incomplete không cho thấy hiệu quả ​

Ngày 09.09.2026, lượt gửi đầu tiên tới người thật: 105 thư nhắc chọn cộng đồng. Sáu người trong số đó vào cộng đồng sau khi nhận, hai người chỉ 6 phút sau. Nghe như 5% chuyển đổi.

Đặt cạnh nền so sánh thì con số đó tan:

NgàyTài khoản CŨ vào cộng đồngCó thư nhắc
09.099✅
08.0911❌
07.091❌
05.098❌
04.098❌

Ngày có thư được 9; ngày không có thư được 11. Nền dao động 1–11, nên một ngày ở mức 9 không chứng minh gì. Báo "5% chuyển đổi" mà bỏ nền là báo một chiến thắng giả.

Vì sao nó không có tác dụng: sản phẩm đã ép làm việc đó rồi ​

com.conan.school/app/root.tsx có cổng cộng đồng: ai đăng nhập mà chưa có "nhà" đều bị đá về /communities?first=1, và màn hình đó bỏ hết đường thoát. Cổng này có từ 03.09.2026.

Nghĩa là ai quay lại app đều buộc phải chọn cộng đồng, đó chính là nguồn của 8–11 lượt mỗi ngày, không phải email. Thư nhắc đang bảo người ta làm một việc mà app sẽ ép họ làm ngay khi mở lên.

Chân dung 99 người chưa có cộng đồng ​

NhómSố
Đã đăng nhập kể từ khi cổng ra đời (03.09)2
Chưa từng đăng nhập lần nào42
Đăng nhập trước 03.09 rồi không quay lại~55

Ba nhóm này cần ba thứ khác nhau, nhưng hiện cả ba nhận cùng một lá thư:

  • 42 người chưa từng đăng nhập chưa hề thấy sản phẩm. Bảo họ "còn một bước nữa" là vô nghĩa, họ chưa bước bước nào.
  • ~55 người đã rời đi cần một LÝ DO để quay lại, không cần mô tả một thao tác. Việc chọn cộng đồng tự lo được khi họ mở app.
  • 2 người đã gặp cổng mà vẫn chưa chọn là tín hiệu về chính màn hình chọn, không phải về email.

Việc đáng làm không phải viết thêm email. Nội dung thư cần cho một lý do quay lại, một thứ cụ thể đang chờ họ trong cộng đồng của họ, và điều đó cần nội dung thật trong cộng đồng trước đã. Xem mục dưới.

Chưa có, và có nên có ​

Danh mục cũ 18 loại ở www.conan.school/lib/email-events.ts (di sản Supabase, không nơi nào tham chiếu) mô tả một lớp email định kỳ: digest tuần, nhắc học dở, sinh nhật, re-engagement. Hệ hiện chỉ có event_reminder chạy theo lịch. Nếu làm thêm, đi theo đúng khuôn đó: một hàm trong reminders.ts, gọi từ nhánh cron, chống gửi trùng bằng email_deliveries, không rải lời gọi vào handler.

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