GĐ12 — File upload, Object Storage & Email
Kiểm chứng ngày 2026-10-05: mã multipart (mục 8) qua
tsc --strictvới@aws-sdk/client-s33.1146.0 và ký URL offline; endpoint huỷ đăng ký (mục 14) đã chạy với Express 5.2.1 và Node 24.21; headerList-Unsubscribedựng bằng nodemailer 10.0.15. Chưa chạy với S3, R2 hay hộp thư Gmail thật. Nguồn: RFC 8058, RFC 9989 (DMARC, 05/2026; thay thế RFC 7489), yêu cầu người gửi của Google. Chưa xác minh: presignedUploadParttrên R2. Hai chỗ sửa sau lần kiểm trên (guardUNSUB_SECRETbắt buộc, trầnMAX_SIZEởstartMultipart) là mã tham chiếu, chưa chạy lại.
Study note cho FE engineer (JS/TS mạnh) chuyển sang Backend. Mỗi concept: định nghĩa → tại sao quan trọng → cơ chế → ví dụ → pitfall. Hai mảng này bị bỏ qua trong hầu hết roadmap nhưng mọi SaaS đều cần: người dùng upload tài liệu (Capstone GĐ25 cần để làm RAG), hệ thống gửi mail verify / reset password / hoá đơn. Cả hai đều có bề mặt tấn công lớn và nhiều bẫy vận hành.
Phần A — File upload & Object Storage#
1. Vì sao upload là bài toán backend khó#
Định nghĩa. Upload = client gửi dữ liệu nhị phân (thường lớn) lên server, server lưu trữ bền vững và trả về định danh để truy cập lại sau.
Tại sao quan trọng. Ở FE bạn viết <input type="file"> + FormData là xong. Ở BE, cùng một request đó mở ra 5 vấn đề cùng lúc:
- Bộ nhớ — file 500MB nạp hết vào RAM là chết process (Node mặc định heap ~1.5–4GB, và bạn có nhiều request đồng thời).
- Bảo mật — file là code do người lạ gửi lên. Tên file, nội dung, kiểu file đều có thể là vũ khí.
- Lưu ở đâu — đĩa server là sai (container ephemeral, không scale ngang được).
- Thời gian — upload chậm giữ connection lâu, dễ timeout ở reverse proxy.
- Nhất quán — file lên storage thành công nhưng DB rollback → file mồ côi, hoặc ngược lại.
Pitfall. Coi upload như một endpoint CRUD bình thường. Nó là luồng dữ liệu không tin cậy, kích thước không giới hạn, từ người lạ. Mọi quyết định thiết kế phải xuất phát từ đó.
2. multipart/form-data — cơ chế thật sự#
Định nghĩa. Content type cho phép gửi nhiều "part" trong một body, mỗi part có header riêng và có thể là nhị phân.
Tại sao quan trọng. Đây là lý do express.json() không parse được upload — nó chỉ hiểu JSON. Bạn cần parser riêng, và parser đó quyết định file đi vào RAM hay đi vào stream.
Cơ chế — body thật trên dây trông như thế này:
Điểm mấu chốt:
boundarylà chuỗi phân tách do client tự chọn. Parser dò chuỗi này để cắt part.filenamevàContent-Typecủa part đều do client tự khai → không tin được (mục 4).- Các field text (như
title) trộn lẫn với file. Nên field text luôn tới trước file trong form — nếu không, khi stream file bạn chưa biết metadata.
Pitfall. Base64-encode file rồi nhét vào JSON để "cho tiện". Base64 làm phình dữ liệu +33%, buộc toàn bộ file vào RAM, và phá vỡ mọi cơ chế stream. Chỉ dùng cho file rất nhỏ (avatar < 100KB) và biết rõ mình đang đánh đổi gì.
3. Nhận file: memory vs disk vs stream#
Định nghĩa. Ba chiến lược parser xử lý byte đến:
- Memory — gom hết vào
Buffertrong RAM. - Disk (temp) — ghi ra file tạm, trả về đường dẫn.
- Stream — đẩy thẳng byte sang đích (S3) khi đang nhận, không lưu trung gian.
Tại sao quan trọng. Đây là quyết định về khả năng sống sót của server. Memory với file lớn = OOM. Stream = hằng số bộ nhớ bất kể file bao to.
Ví dụ — Express + multer (memory, chỉ cho file nhỏ):
Ví dụ — stream thẳng lên S3, bộ nhớ không đổi (file lớn):
Hai giới hạn khác nhau, hai sự kiện khác nhau. limits.fileSize chặn kích thước của
từng file và báo bằng sự kiện limit trên stream của file đó (cờ
fileStream.truncated). limits.files chặn số file, báo bằng sự kiện filesLimit
trên busboy. Nhầm hai thứ này là lỗi kinh điển: handler chỉ nghe filesLimit thì file
vượt kích thước vẫn được Upload đọc đến hết phần đã bị cắt và lưu lên S3.
Vì sao cần reply một lần. res.json hai lần ném ERR_HTTP_HEADERS_SENT. Ở đây
có tới ba nguồn có thể trả lời (limit, filesLimit, upload.done() hoàn tất hoặc lỗi),
và done() reject sau khi body.destroy() ngắt luồng, ngay sau khi ta đã trả 413.
Khách hàng có thể thấy ECONNRESET/EPIPE thay vì 413. Khi server trả lời và đóng
kết nối giữa lúc client còn đang gửi thân request, một số client (kể cả fetch của
Node) báo lỗi kết nối chứ không đọc được mã 413; curl thường đọc được. Đây là bản
chất của HTTP/1.1, không phải lỗi của đoạn code trên. Vì thế lớp bảo vệ đáng tin nhất
vẫn là chặn trước khi gửi: client kiểm kích thước và dùng presigned URL (mục 6), còn
server kiểm Content-Length như trên.
Mức kiểm chứng: đã chạy thật busboy 1.6 + @aws-sdk/lib-storage 3.1146 với S3 giả
(một HTTP server nhận các lệnh S3 và ghi lại object, lệnh huỷ, lệnh xoá), chưa chạy trên
S3/R2 thật. Một handler chỉ nghe filesLimit, với giới hạn 1 MiB, lưu file 3 MiB thành
object 1 MiB và trả 201. Mã ở trên trả 413, không còn object nào sau khi xong, file hợp lệ
vẫn 201; đã chạy đủ các đường: limit (multipart chunked không có Content-Length),
filesLimit, thiếu file, client bị kill giữa chừng, và multipart hỏng sau khi file đã lên S3.
Hai điểm dễ sai trong cleanup(), cả hai đã gặp khi chạy:
(1) thiếu body.destroy() thì ở đường ngắt giữa chừng S3 giả chỉ nhận tạo multipart, một part
và lệnh xoá, không nhận AbortMultipartUpload (đã chờ 6 giây);
(2) gọi upload.abort() làm done() reject ngay trong khi PutObject của file dưới 5 MiB
còn đang bay, nên DeleteObject đi trước và object đến sau ở lại (kịch bản: file 1 MiB rồi
một part hỏng; lệnh xoá đi trước lệnh ghi ở 11/12 lần với độ trễ ghi 0 ms và 12/12 lần với
20 ms và 50 ms). Mã hiện tại không gọi abort(), chỉ đóng stream và đợi done() xong hẳn
rồi mới xoá: 20 lần cho mỗi mức độ trễ ghi 0, 5, 100 ms với file 1 MiB và 0, 30, 200 ms với
file 12 MiB đều không còn object. Race này phụ thuộc độ trễ của S3 nên các con số trên chỉ
đúng với S3 giả; hãy thử lại với bucket của bạn. Dù đã huỷ, vẫn nên đặt lifecycle rule
"abort incomplete multipart uploads" cho bucket (mục 8) vì process chết đột ngột thì không
còn ai gọi huỷ.
Ví dụ — NestJS:
Pitfall. Quên limits.fileSize. Mặc định của multer là không giới hạn — một request duy nhất gửi file 20GB làm sập server. Và nhớ rằng reverse proxy cũng có limit riêng: Nginx mặc định client_max_body_size 1m → upload 5MB trả 413 trước khi chạm tới Node. Phải chỉnh cả hai tầng cho khớp.
4. Validate — file là dữ liệu thù địch#
Định nghĩa. Kiểm chứng file trước khi lưu: kích thước, kiểu thật, tên, nội dung.
Tại sao quan trọng. Content-Type và filename do client khai. Kẻ tấn công đổi tên shell.php thành avatar.png và khai Content-Type: image/png trong 5 giây. Nếu bạn tin, bạn đang cho người lạ ghi file tuỳ ý lên hệ thống.
Cơ chế — 4 lớp kiểm tra:
(a) Magic bytes — kiểu THẬT của file. Mọi định dạng có chữ ký nhị phân ở đầu file: PNG = 89 50 4E 47, PDF = %PDF, JPEG = FF D8 FF.
(b) Tên file — không bao giờ dùng tên client gửi.
(c) Kích thước — kiểm ở 3 tầng. Reverse proxy (client_max_body_size) → parser (limits.fileSize) → và với presigned URL thì thêm policy phía S3 (mục 6). Không tin Content-Length client khai.
(d) Nội dung nguy hiểm dù đúng định dạng.
- SVG là XML → chứa được
<script>→ stored XSS khi ai đó mở. Coi SVG như HTML: hoặc cấm, hoặc sanitize (DOMPurify), hoặc chỉ phục vụ từ domain khác +Content-Disposition: attachment. - ZIP → zip bomb (file 42KB giải nén thành 4.5PB) và zip slip (entry tên
../../). Nếu giải nén, phải giới hạn tổng dung lượng và số entry, và chuẩn hoá mọi đường dẫn. - Office/PDF có macro và JS nhúng. Nếu người dùng khác tải về được, cần quét virus (ClamAV, hoặc dịch vụ của cloud).
Pitfall. Phục vụ file người dùng upload từ cùng domain với app. Một file HTML/SVG upload lên sẽ chạy JS trong origin của bạn, đọc được cookie session → chiếm tài khoản. Luôn phục vụ user content từ domain riêng (usercontent-abc.com, hoặc R2/S3 domain) và set Content-Disposition: attachment + X-Content-Type-Options: nosniff.
5. Lưu ở đâu — object storage vs filesystem vs DB#
Định nghĩa. Object storage (S3, Cloudflare R2, GCS) = kho key→blob qua HTTP, dung lượng gần như vô hạn, có phiên bản, lifecycle, CDN.
Tại sao quan trọng. Đây là quyết định kiến trúc, không phải sở thích.
| Nơi lưu | Ưu | Nhược | Kết luận |
|---|---|---|---|
| Đĩa server | Đơn giản nhất | Container restart là mất sạch; chạy 2 instance thì instance B không thấy file của A; backup thủ công | Chỉ dùng cho file tạm trong 1 request |
| Database (BYTEA/BLOB) | Có transaction, backup chung | Phình DB, backup/restore chậm khủng khiếp, tốn RAM cache của DB cho dữ liệu không truy vấn được | Chỉ khi file rất nhỏ (<100KB) và cần tính nguyên tử tuyệt đối |
| Object storage | Rẻ, vô hạn, CDN sẵn, scale ngang, có lifecycle | Không có transaction với DB (mục 10) | Mặc định. Chọn cái này. |
Cơ chế S3 tối thiểu cần nắm: bucket (thùng chứa) → key (đường dẫn phẳng, dấu / chỉ là quy ước hiển thị) → object (bytes + metadata). Bucket mặc định private; truy cập bằng credential hoặc presigned URL.
Ví dụ — client S3 dùng chung được cho R2:
Thiết kế key — quan trọng hơn vẻ ngoài:
Tiền tố tenant giúp phân quyền và xoá theo tenant dễ. Đừng để tên file người dùng, đừng để thông tin nhạy cảm trong key (key hay lọt vào log, Referer).
Pitfall. Bật public read cho cả bucket vì "cho tiện". Toàn bộ tài liệu của mọi khách hàng chỉ cách nhau một lần đoán key — và bot quét bucket công khai chạy 24/7. Bucket luôn private; truy cập qua presigned URL (mục 7).
6. Presigned URL — cho client upload thẳng lên storage#
Định nghĩa. URL có chữ ký, hết hạn theo thời gian, cho phép người cầm nó thực hiện đúng một thao tác (PUT hoặc GET) lên đúng một key, mà không cần credential.
Tại sao quan trọng. Với luồng "client → server → S3", mọi byte đi xuyên qua server bạn: tốn băng thông, chiếm worker, giữ connection lâu, giới hạn bởi request timeout của PaaS (nhiều nơi 30–100s). Presigned URL cho phép client bắn thẳng lên S3, server chỉ ký giấy phép — nhanh, rẻ, scale vô hạn.
Cơ chế — luồng 3 bước:
Ví dụ — bước 1 và 3:
Pitfall.
- Không ghim
ContentLength/ContentTypevào chữ ký → client xin ký cho file 1MB rồi upload 10GB. Phải đưa vào lệnh ký, và nhớContentLengthmặc định có trong chữ ký cònContentTypethì không: phải truyềnsignableHeaders: new Set(['content-type'])chogetSignedUrl(đã chạy@aws-sdk/client-s33.1146.0,X-Amz-SignedHeadersmặc định chỉ cócontent-length;host). Trên AWS S3 còn có lựa chọn presigned POST policy (khaicontent-length-rangelinh hoạt hơn); Cloudflare R2 không hỗ trợ presigned POST (tài liệu R2 liệt kê presigned URL cho GET, HEAD, PUT, DELETE và ghiPOSTkhông được hỗ trợ), nên với R2 bạn chỉ có presigned PUT. Việc R2 có thực sự épContentLengthđã ký trên presigned PUT hay không: chưa xác minh. Dù thế nào, đừng coi chữ ký là chốt chặn cuối: bướcHeadObjectkiểm size ở bước 3 mới là chốt chặn thật, và worker xử lý sau upload (mục 9) kiểm lại magic byte. - Tin bước 3 mà không
HeadObject→ DB đầy bản ghiREADYtrỏ tới object không tồn tại. expiresInquá dài (7 ngày) → URL lọt vào log/lịch sử chat là ai cũng ghi đè được key đó.- Quên CORS trên bucket → trình duyệt chặn PUT từ origin của bạn. Đây là lỗi đầu tiên ai cũng gặp; cấu hình CORS rule ở phía S3/R2, không phải ở server.
7. Cho tải xuống — file private#
Định nghĩa. Ngược lại của mục 6: presigned GET URL, hết hạn ngắn, cấp sau khi đã kiểm tra quyền.
Cơ chế — và vì sao không stream qua server:
404 hay 403 cho file của người khác? Quy ước xuyên suốt lộ trình: trả 404 khi
không muốn lộ sự tồn tại của tài nguyên, 403 chỉ khi việc nó tồn tại là công khai.
File của một người dùng (hay tenant) khác rơi vào trường hợp đầu: nếu trả 403, kẻ tấn
công dò fileId biết ngay id nào có thật và thu hẹp việc đoán. Truy vấn
findFirst({ id, userId }) ở trên cho ra 404 giống hệt nhau cho "không tồn tại" và
"của người khác", nên không có kênh phụ nào để phân biệt (kiểm cả thời gian phản hồi
nếu dữ liệu nhạy cảm). Trường hợp dùng 403: người dùng biết rõ tài nguyên có thật
nhưng thiếu quyền, ví dụ tài liệu trong cùng workspace mà vai trò MEMBER không được
xoá; khi đó 404 chỉ gây khó hiểu. Cùng quy ước được kiểm bằng test ở
GĐ13 mục 7.
Pitfall. Trả presigned URL trong list API (100 file = 100 URL ký sẵn, phần lớn không dùng, và lộ hết nếu response bị log). Chỉ ký khi người dùng thực sự bấm tải.
8. File lớn — multipart upload & resumable#
Định nghĩa. S3 multipart upload: chia file thành part (tối thiểu 5MB, trừ part cuối), upload song song, rồi gọi CompleteMultipartUpload để ghép.
Tại sao quan trọng. Upload một mạch 5GB qua mạng di động gần như chắc chắn đứt giữa chừng — và bạn phải làm lại từ đầu. Multipart cho phép retry từng part, và upload song song nhanh hơn nhiều.
Cơ chế. CreateMultipartUpload → nhận uploadId → ký presigned URL cho từng part → client PUT từng part, nhận ETag → gửi danh sách {PartNumber, ETag} về server → CompleteMultipartUpload.
Sơ đồ: luồng multipart với presigned URL cho từng part
Mọi part (trừ part cuối) phải từ 5 MiB trở lên; client giữ danh sách ETag nên mất trang là mất
tiến độ, vì vậy lưu uploadId và các part đã xong (DB hoặc localStorage) để nối lại.
Sơ đồ suy ra từ tài liệu S3 multipart; chưa chạy.
Pitfall vận hành. Upload dở dang vẫn tính tiền lưu trữ nhưng không hiện trong list object — hoá đơn phình lên mà không hiểu vì sao. Bắt buộc bật lifecycle rule AbortIncompleteMultipartUpload sau 7 ngày. Đây là lỗi tốn tiền âm thầm phổ biến nhất với S3.
Multipart với presigned URL: mã tham chiếu cho từng bước#
Bốn endpoint: bắt đầu, ký từng lô part, hoàn tất, huỷ. Điểm quyết định tính đúng:
- Kích thước part do server chọn, một giá trị cho mọi part trừ part cuối. S3 đòi part (trừ part cuối) từ 5 MiB; tài liệu R2 đòi các part cùng kích thước trừ part cuối. Tối đa 10.000 part, nên với object lớn phải tăng cỡ part (
max(8 MiB, size / 10000); 100 GiB cần part khoảng 10,24 MiB). - Ký theo lô khi client cần, không ký sẵn 10.000 URL. Mỗi URL ghim
ContentLengthcủa đúng part đó vào chữ ký (cùng lý do như mục 6). - Hoàn tất không dựa vào danh sách ETag của client. Server hỏi lại storage bằng
ListParts(phân trang: mỗi trang tối đa 1000 part), kiểm số part, thứ tự và kích thước rồi mớiCompleteMultipartUpload, sau đóHeadObjectkiểm tổng size như mục 6. Vì server tự lấy ETag nên client không cần đọc headerETag; nếu thư viện client của bạn cần đọc, thêmExposeHeaders: ["ETag"]vào CORS của bucket.
Mức kiểm chứng: tsc --strict sạch với @aws-sdk/client-s3 3.1146.0; ký UploadPartCommand offline (ký là phép tính cục bộ) cho ra URL có partNumber, uploadId, X-Amz-SignedHeaders=content-length;host; với requestChecksumCalculation: 'WHEN_REQUIRED' (mục 5) URL không còn x-amz-checksum-crc32, còn mặc định thì có. Chưa chạy với S3 hay R2 thật. Tài liệu R2 liệt kê presigned URL cho GET, HEAD, PUT, DELETE và có UploadPart, ListParts trong bảng API S3 được hỗ trợ; việc một URL ký cho UploadPart chạy được trên R2: chưa xác minh, hãy thử trên bucket của bạn (nếu không được, dùng Worker binding createMultipartUpload).
Tải lên có thể nối lại sau khi đứt hẳn kết nối (giao thức tus, thư viện Uppy) nằm ngoài phạm vi giai đoạn này; xem tus.io và uppy.io. Kỹ thuật ở đây (lưu uploadId rồi ListParts để biết part nào đã lên) là nền của chúng.
9. Xử lý sau upload — luôn bất đồng bộ#
Định nghĩa. Việc nặng sau khi file lên: resize ảnh, trích text từ PDF, chunk + embed cho RAG (GĐ23), quét virus, transcode video.
Tại sao quan trọng. Trích text một PDF 200 trang mất 30 giây. Làm trong request handler = timeout, và CPU-bound làm nghẽn event loop của toàn bộ server (GĐ03).
Cơ chế. Upload xong → enqueue BullMQ job (GĐ10) → trả 202 Accepted + status: PROCESSING → worker xử lý → cập nhật status: READY → thông báo (WebSocket/polling).
Ví dụ — resize ảnh trong worker:
Pitfall.
- EXIF chứa GPS. Ảnh chụp từ điện thoại có toạ độ chính xác nhà người dùng. Public ảnh mà không strip metadata = rò rỉ vị trí.
sharpmặc định bỏ metadata khi xử lý — nhưng nếu bạn lưu file gốc và phục vụ trực tiếp thì không. - Decompression bomb: ảnh PNG 10KB khai kích thước 50.000×50.000 → giải nén ra ~10GB RAM → OOM.
limitInputPixelschặn việc này.
10. File ↔ DB: bài toán nhất quán#
Định nghĩa. Object storage không tham gia transaction của Postgres. Hai hệ thống lưu trữ độc lập → luôn có khe hở.
Tại sao quan trọng. Hai chiều hỏng:
- Orphan file: upload S3 xong,
db.createlỗi → file nằm đó vĩnh viễn, tốn tiền, không ai biết. - Broken record: DB có bản ghi, S3 không có object → app crash khi mở.
Cơ chế — hai nguyên tắc:
(a) DB là nguồn sự thật, ghi DB trước. Bản ghi PENDING tạo trước khi ký URL (mục 6). Orphan trong DB thì vô hại và dễ dọn; orphan trong S3 thì vô hình.
(b) Xoá thì làm ngược lại, và làm mềm.
(c) Job dọn định kỳ (cron BullMQ, hàng đêm):
- Object
PENDINGquá 24h mà chưaREADY→ xoá object + bản ghi. - Đối soát: liệt kê key trên S3, so với DB → báo cáo lệch (đừng xoá tự động ngay, hãy alert trước).
Pitfall. Xoá bản ghi DB bằng DELETE cứng mà quên xoá object → chi phí storage tăng đều mỗi tháng và không ai lần ra được nguyên nhân, vì DB không còn dấu vết gì về những key đó.
Phần B — Email#
11. Transactional vs marketing — và vì sao phải tách#
Định nghĩa.
- Transactional — gửi cho một người do hành động của họ: verify email, reset password, hoá đơn, cảnh báo hết quota.
- Marketing/bulk — gửi cho nhiều người theo lịch của bạn: newsletter, khuyến mãi.
Tại sao quan trọng. Đây không phải phân loại hình thức mà là quyết định hạ tầng:
- Pháp lý khác nhau: marketing cần opt-in + link unsubscribe (GDPR, CAN-SPAM). Transactional thì không.
- Reputation lây chéo: một chiến dịch marketing bị nhiều người bấm "spam" sẽ kéo tụt uy tín của IP/domain đang gửi. Nếu dùng chung, email reset password cũng rơi vào spam → người dùng không đăng nhập được → mất khách hàng thật.
Cơ chế. Tách subdomain gửi: mail.myapp.com cho transactional, news.myapp.com cho marketing. Hai domain có reputation độc lập. Không bao giờ gửi từ domain gốc (myapp.com) — hỏng reputation là ảnh hưởng cả email công ty.
Pitfall. Nhét link "đăng ký nhận tin" hay banner khuyến mãi vào email hoá đơn → email đó thành marketing về mặt phân loại của bộ lọc spam, và về mặt pháp lý.
12. SMTP vs API provider#
Định nghĩa. SMTP là giao thức gốc để gửi mail. Email API (Resend, Postmark, SendGrid, SES) là HTTP API bọc ngoài, kèm dịch vụ vận hành.
Cơ chế & lựa chọn:
| SMTP thuần (tự dựng) | Email API provider | |
|---|---|---|
| Deliverability | Bạn tự lo IP reputation | Provider lo, có IP pool đã warm |
| Bounce/complaint | Tự parse mail trả về | Webhook có cấu trúc |
| Chi phí | "Rẻ" nhưng tốn thời gian vận hành | Vài chục nghìn email đầu thường free |
| Cổng 25 | Hầu hết cloud chặn cổng 25 outbound | Không liên quan |
Kết luận: dùng provider. Tự dựng mail server để gửi mail sản phẩm là quyết định gần như luôn sai — deliverability là bài toán reputation nhiều năm, không phải bài toán kỹ thuật.
Ví dụ — abstraction để không khoá cứng vào một nhà cung cấp:
Pitfall dev. Dùng API thật ở môi trường dev → gửi mail nhầm cho email thật của khách hàng khi test với dữ liệu sao chép từ prod. Ở dev dùng Mailpit/MailHog (SMTP giả, có UI xem mail trong trình duyệt) hoặc sandbox mode của provider. Và trên staging, luôn có allowlist domain người nhận.
13. Deliverability — SPF, DKIM, DMARC#
Định nghĩa. Ba bản ghi DNS chứng minh bạn có quyền gửi mail thay mặt domain.
- SPF — liệt kê server/IP nào được phép gửi cho domain này.
- DKIM — chữ ký số trên nội dung mail; người nhận lấy public key từ DNS để xác minh mail không bị sửa và đúng nguồn.
- DMARC — chính sách: nếu SPF/DKIM fail thì làm gì (
none= kệ,quarantine= vào spam,reject= từ chối), và gửi báo cáo về đâu.
Tại sao quan trọng. Không có ba bản ghi này, mail của bạn vào thẳng spam ở Gmail/Outlook. Từ 2024, Gmail và Yahoo bắt buộc SPF + DKIM + DMARC với người gửi số lượng lớn. Đây không còn là "nên có".
Ví dụ — DNS records:
Cơ chế "alignment" — chỗ hay sai. DMARC yêu cầu domain đã xác thực bằng SPF hoặc DKIM phải khớp với domain trong From:. Chế độ mặc định là relaxed: chỉ cần cùng Organizational Domain, nên chữ ký DKIM d=mail.myapp.com với From: no-reply@myapp.com vẫn aligned. Lỗi thật hay gặp là provider ký bằng domain của chính họ (ví dụ d=provider.example) khi From: là @myapp.com: DKIM pass nhưng DMARC fail vì không aligned. Chi tiết ở phần alignment ngay dưới mục này.
Thực hành vận hành:
- Bắt đầu
p=none, đọc báo cáoruavài tuần, xác nhận không có luồng mail hợp lệ nào bị fail → mới nâng lênquarantinerồireject. - Domain mới cần warm-up: tăng dần lượng gửi trong 2–4 tuần. Bắn 100.000 mail từ domain mới toanh trong ngày đầu = bị chặn ngay.
- Theo dõi bounce rate (<2%) và complaint rate (<0.1%). Vượt ngưỡng là provider sẽ khoá tài khoản.
Pitfall. Dùng From: user@gmail.com (email của người dùng) để gửi thay họ → SPF/DKIM của Gmail fail vì bạn không phải Gmail → DMARC reject của Gmail chặn thẳng. Cách đúng: From: notifications@mail.myapp.com + Reply-To: user@gmail.com.
Alignment chi tiết: SPF, DKIM và chế độ relaxed/strict#
Mỗi cơ chế đem một domain khác nhau so với From::
| Cơ chế | Domain đem so với From: | Chỗ dễ sai |
|---|---|---|
| SPF | domain trong envelope MAIL FROM (header Return-Path), không phải From: | provider dùng Return-Path của họ: SPF pass nhưng không aligned |
| DKIM | tag d= của chữ ký | chưa thêm bản ghi DKIM của domain mình nên provider ký bằng domain của họ |
DMARC pass khi ít nhất một trong hai vừa pass vừa aligned, nên chỉ cần DKIM aligned là đủ dù SPF lệch. Hai tag đổi chế độ, mặc định đều là r: adkim=r|s cho DKIM, aspf=r|s cho SPF. Relaxed: cùng Organizational Domain; RFC 9989 xác định domain này bằng DNS Tree Walk (RFC 7489 cũ dùng Public Suffix List, và người nhận cũ có thể vẫn làm theo cách đó). Strict: trùng chính xác.
From: | DKIM d= | adkim | DKIM aligned? |
|---|---|---|---|
| news@myapp.com | mail.myapp.com | r (mặc định) | có, cùng myapp.com |
| news@myapp.com | mail.myapp.com | s | không |
| news@myapp.com | provider.example | r hoặc s | không |
Tag p= là chính sách của domain có bản ghi; sp= là chính sách cho subdomain không có bản ghi DMARC riêng. Người nhận tra _dmarc.<domain trong From> trước, không có thì dùng _dmarc của Organizational Domain. Ví dụ gửi từ mail.myapp.com mà chỉ có _dmarc.myapp.com với p=none; sp=quarantine: mail fail từ subdomain bị xử lý theo quarantine, không phải none. Siết p= lên domain gốc mà quên sp= là cách làm lộ lỗ hổng ở subdomain; đặt sp= có chủ ý. RFC 9989 thêm tag np= cho subdomain không tồn tại và đưa pct vào trạng thái historic, nên ví dụ bản ghi ở trên không còn pct.
Google yêu cầu From: aligned với domain SPF hoặc domain DKIM (trang yêu cầu người gửi). Quy tắc alignment và lookup nằm trong RFC 9989 (mục 4.10 là DNS Tree Walk; RFC 9989 thay thế RFC 7489, kiểm trên rfc-editor.org ngày 2026-10-05).
14. Gửi email đúng cách trong hệ thống#
Định nghĩa. Email là I/O ra ngoài, chậm, có thể lỗi — phải xử lý như mọi external call: async, retry, idempotent.
Ví dụ — sai và đúng:
Idempotency — chống gửi trùng:
Bounce & complaint webhook — bắt buộc:
Và kiểm tra suppression list trước mỗi lần gửi.
Nội dung mail — luôn kèm bản text:
Pitfall bảo mật — 2 cái phải nhớ:
-
User enumeration qua reset password. Trả
"Email không tồn tại"cho phép kẻ tấn công dò xem ai có tài khoản. Luôn trả cùng một thông báo ("Nếu email tồn tại, chúng tôi đã gửi hướng dẫn") và mất cùng khoảng thời gian trong cả hai trường hợp. -
Header injection. Nhét dữ liệu người dùng thẳng vào subject/header:
Và escape HTML trong body — email cũng dính XSS (một số webmail render HTML).
Token trong link email:
Huỷ đăng ký một chạm cho mail marketing#
Mail marketing cần cách huỷ đăng ký mà người nhận bấm được ngay trong giao diện Gmail/Yahoo, không phải mở trang web. Chuẩn là RFC 8058: hai header.
- Chỉ một header
List-Unsubscribe, các URI cách nhau bằng dấu phẩy; có ít nhất một URIhttps. RFC 8058 yêu cầu cả hai header nằm trong chữ ký DKIM (tagh=). - Khi người dùng bấm, máy chủ của người nhận gửi
POSTtới URI https với bodyList-Unsubscribe=One-Click. Endpoint này không được đòi cookie hay đăng nhập và không được redirect. GETtới cùng URL không được huỷ đăng ký: bộ quét link và trình xem trước mail gọiGETtự động.GETchỉ hiện trang xác nhận.- Google yêu cầu người gửi hơn 5.000 mail/ngày tới Gmail phải hỗ trợ one-click cho mail marketing và mail đã đăng ký, kèm link huỷ nhìn thấy được trong thân mail (nguồn). Dưới ngưỡng đó, làm theo vẫn hợp lý: người không tìm được nút huỷ thường bấm "báo spam".
Dựng header với nodemailer: truyền qua headers với một chuỗi đã gộp. Tuỳ chọn list: { unsubscribe: [...] } của nodemailer 10.0.15 phát ra hai dòng List-Unsubscribe riêng, không phải dạng một header như trên (đã chạy kiểm).
Endpoint, với token ký HMAC để link không đoán được và không cần bảng tra:
Người đã huỷ marketing vẫn phải nhận mail giao dịch (reset password, hoá đơn): bảng marketingOptOut khác emailSuppression ở trên, và chỉ worker marketing mới kiểm nó.
Mức kiểm chứng: tsc --strict sạch; đã chạy trên Node 24.21 và Express 5.2.1: GET trả 200 và ghi 0 lần, POST (body List-Unsubscribe=One-Click, không theo redirect) trả 200 và ghi đúng một lần, token sai trả 404 và không ghi thêm. Header dựng bằng nodemailer đã in ra đúng một dòng gộp. Chưa gửi thật tới Gmail; nếu dùng provider (Resend, SES), kiểm trong "Show original" rằng h= của DKIM-Signature có cả hai header.
Thực hành#
Nâng cấp Dự án 3 (GĐ07):
A — Upload:
-
Endpoint
POST /files/upload-urlcấp presigned PUT (kiểm auth + quota + mime + size), tạo bản ghiPENDING.Đáp án
Validate bằng zod, rồi dùng đúng
createUploadUrlở mục 6 (ghimContentLength;ContentTypechỉ vào chữ ký nhờsignableHeaders;expiresIn: 300). GiữrequestChecksumCalculation: 'WHEN_REQUIRED'ở client (mục 5). Mong đợi: JSON{ uploadUrl, fileId };uploadUrlcóX-Amz-Expires=300vàX-Amz-Signature; bản ghiPENDING. Với SDK mặc định, URL còn cóx-amz-checksum-crc32=AAAAAA==; bỏ nó bằngWHEN_REQUIRED(việc S3 thật từ chối URL mặc định là suy luận, chưa xác minh). -
Endpoint
POST /files/:id/confirm→HeadObjectxác minh →READY→ enqueue job.Đáp án
typescriptReadyMong đợi: confirm khi chưa PUT → 400; confirm với id của người khác → 404; confirm hai lần → lần hai 200 (idempotent, không enqueue thêm); hai request song song chỉ một request đổi trạng thái nhờ
updateManycó điều kiệnPENDING. -
Worker: validate magic bytes từ object đã upload (không tin mime lúc xin URL), resize ảnh bằng
sharp, strip EXIF.Đáp án
Chỉ đọc đầu object, không tải cả file:
typescriptReadyMong đợi: file PNG thật →
READY+ bản thumbnail; file HTML đổi tên →REJECTED, object bị xoá. -
GET /files/:id/download→ kiểm quyền → presigned GET 60s +Content-Disposition: attachment→ 302.Đáp án
Dùng handler ở mục 7 (
findFirst({ id, userId }),expiresIn: 60,attachment). Mong đợi: người sở hữu nhận 302,LocationcóX-Amz-Expires=60vàresponse-content-disposition=attachment...; người khác nhận 404 giống hệt id không tồn tại. -
Xoá mềm + job purge hoãn 7 ngày. Cron dọn
PENDINGquá 24h.Đáp án
Xoá mềm như mục 10. Cron bằng BullMQ 6 (không dùng
repeat):typescriptReady -
Bật lifecycle
AbortIncompleteMultipartUploadtrên bucket.Đáp án
Với AWS S3:
bashReadyR2 cấu hình lifecycle riêng (dashboard/Wrangler); xem tài liệu R2, cách cấu hình chưa xác minh ở đây.
-
Tự tấn công: đổi tên
test.html→test.png, gửi lên → xác nhận bị chặn ở magic bytes. Thửfilename="../../etc/passwd"→ xác nhận key sinh ra vẫn an toàn.Đáp án
Test (Vitest, chưa chạy; kiểm hai hàm thuần nên không cần S3 hay supertest):
typescriptReadyMong đợi:
test.htmlđổi têntest.pngbịREJECTED;filename="../../etc/passwd"chỉ nằm trong cộtoriginalName(nên cắt bằngpath.basenamevà độ dài), key sinh ra vẫn làtenants/<user>/docs/<uuid>.<ext>. -
Làm multipart với presigned URL cho file lớn (bốn endpoint: bắt đầu, ký lô part, hoàn tất, huỷ).
Đáp án
Dùng mã ở mục 8 (phần multipart với presigned URL). Với file 20 MiB, part 8 MiB cho ba part, độ dài 8, 8 và 4 MiB (đã tính bằng
node -e); file 100 GiB cần part 10,24 MiB để đủ trong 10.000 part. Kiểm tra bằng bốn tình huống:textReadyCode tham chiếu:
tsc --strictđã chạy, ký URL offline đã chạy; bốn tình huống trên suy ra từ code, chưa chạy với S3/R2 thật (không dựng hạ tầng trong lời giải). Trên R2 hãy thử riêng việc kýUploadPart; nếu không được, dùng Worker binding.
Khung và mã dùng chung
Tự làm trước, rồi mở. Toàn bộ là code tham chiếu, chưa chạy (SDK @aws-sdk/client-s3 3.x, BullMQ 6,
Prisma 7, file-type); mục "Kết quả mong đợi" suy ra từ code và tài liệu, không phải quan sát.
Lỗi hay gặp: tin Content-Type lúc xin URL thay vì kiểm magic bytes sau upload; confirm không HeadObject;
lấy findUnique({ id }) làm lộ file người khác; quên CORS khi PUT từ trình duyệt; cron dọn xoá object nhưng quên
dòng DB (hoặc ngược lại).
B — Email:
-
EmailPort+ 2 adapter (Resend cho prod, Mailpit SMTP cho dev qua Docker Compose).Đáp án
Dev dùng Mailpit trong compose (SMTP cổng 1025, giao diện 8025):
textReadytypescriptReadyMong đợi: sau signup mở
http://localhost:8025thấy mail có cả tab HTML và Text. -
Luồng verify email: outbox → BullMQ → gửi, có
idempotencyKey, token hash + hết hạn 30 phút + dùng một lần.Đáp án
Token hash + hết hạn + dùng một lần, đánh dấu dùng trong một câu lệnh để hai request song song không cùng thắng:
typescriptReadyidempotencyKeycủa job gửi mail:verify:${userId}:${tokenId}(dùng id bản ghi token, không dùng token thô trong key). Mong đợi: dùng lại cùng link lần hai → 400; quá 30 phút (fake timers) → 400; DB không chứa token thô. -
Reset password: cùng thông báo cho mọi trường hợp (chống enumeration).
Đáp án
Luôn trả cùng một phản hồi, việc tìm user và gửi mail chạy ở worker:
typescriptReadyMong đợi: email có và không có trong DB cho cùng mã 202, cùng body, thời gian phản hồi tương đương (không chạm DB trong handler).
-
Webhook bounce/complaint (verify HMAC — GĐ09 mục 15) → suppression list → kiểm tra trước khi gửi.
Đáp án
HMAC trên raw body, so sánh thời gian cố định, kiểm tra suppression trước khi gửi:
typescriptReadyTên header và công thức ký (có thêm timestamp hay không) là của từng provider; đối chiếu tài liệu provider. Cơ chế HMAC chung: GĐ09 mục 15. Mong đợi: chữ ký sai → 401, không ghi DB; chữ ký đúng → có dòng suppression; gửi mail tới địa chỉ đó sau này bị bỏ qua.
-
Cấu hình SPF + DKIM + DMARC (
p=none) cho một subdomain thật. Gửi thử tới Gmail, mở "Show original" và xác nhậnSPF: PASS,DKIM: PASS,DMARC: PASS.Đáp án
Tạo bản ghi theo mục 13 cho subdomain gửi, rồi kiểm:
bashReadyChưa chạy (cần domain thật). Trong Gmail, "Show original" nên có dòng dạng sau (mẫu minh hoạ, không phải quan sát):
textReadyDKIM PASS mà DMARC FAIL nghĩa là domain trong
From:không aligned với domain ký/SPF (mục 13). TrongAuthentication-Results, soheader.from=vớiheader.d=(DKIM) vàsmtp.mailfrom=(SPF): đó là ba domain đem đối chiếu. -
Thêm huỷ đăng ký một chạm cho mail marketing: header
List-Unsubscribe, endpointPOSTvà bảngmarketingOptOut.Đáp án
Dùng header và endpoint ở mục 14 (phần huỷ đăng ký một chạm). Tự kiểm bằng
curltrên server dev:bashReadyMong đợi: GET không ghi; POST ghi đúng một dòng (chạy lại vẫn một dòng nhờ
upsert); worker marketing bỏ qua địa chỉ có trong bảng, worker giao dịch thì không. Đã chạy ba request tương ứng bằngfetchvới server tạm (GET 200 và 0 lần ghi; POST 200 và 1 lần ghi; token sai 404); chưa thử với Gmail thật.
Khung và mã dùng chung
Tự làm trước, rồi mở. Code tham chiếu, chưa chạy (nodemailer, Resend SDK, BullMQ 6); kết quả "mong đợi" suy ra từ code. Việc gửi tới Gmail và đọc header cần domain thật nên không chạy được trong lời giải.
Lỗi hay gặp: gửi mail trong handler HTTP; express.json() chạy trước webhook nên mất raw body; log token thô; so sánh chữ ký bằng ===;
trả "Email không tồn tại" ở reset; để SPF có hai bản ghi TXT.
Done khi#
Upload / Storage
-
Giải thích được cấu trúc
multipart/form-datavà vì saoexpress.json()không parse được.Đáp án
Body gồm các part ngăn bởi
boundarydo client chọn; mỗi part có header (Content-Disposition,Content-Type) rồi nội dung.express.json()chỉ parseapplication/json, gặp multipart thìreq.bodyrỗng/undefined. Sai thường gặp: base64 vào JSON (phình 33%, nằm hết trong RAM). Xem mục 2. -
Phân biệt memory / disk / stream; biết khi nào bắt buộc stream.
Đáp án
Memory gom vào
Buffer(file nhỏ, đã giới hạn), disk ghi tạm, stream đẩy thẳng sang đích với bộ nhớ gần như hằng số. Bắt buộc stream khi file lớn hoặc nhiều request đồng thời; multer mặc định không giới hạn kích thước nên luôn đặtlimits.fileSize. -
Đặt limit ở cả reverse proxy và parser; giải thích lỗi 413 đến từ đâu.
Đáp án
Nginx
client_max_body_size(mặc định 1m) trả 413 trước khi tới Node; parser (limits.fileSize) trả 413 từ app. Chỉnh hai tầng khớp nhau. Tự kiểm: gửi file lớn hơn từng giới hạn, xem body lỗi để biết tầng nào trả. -
Validate bằng magic bytes + allowlist, không tin
Content-Typevàfilename.Đáp án
fileTypeFromBufferđọc chữ ký thật (PNG89 50 4E 47, PDF%PDF), đối chiếu allowlist;Content-Typevàfilenamedo client khai nên chỉ là metadata. Tự kiểm:test.htmlđổi tên.pngbị từ chối. -
Nêu được 3 rủi ro của SVG / ZIP / ảnh bomb và cách chặn từng cái.
Đáp án
SVG chứa
<script>(stored XSS): cấm, sanitize, hoặc phục vụ từ domain khác +attachment. ZIP: zip bomb và zip slip: giới hạn tổng dung lượng, số entry, chuẩn hoá đường dẫn. Ảnh bomb (khai kích thước khổng lồ):limitInputPixelscủasharp. -
Giải thích vì sao user content phải phục vụ từ domain khác app.
Đáp án
File HTML/SVG phục vụ cùng origin chạy JS trong origin của app và đọc được cookie/storage. Dùng domain riêng,
Content-Disposition: attachment,X-Content-Type-Options: nosniff. -
Triển khai đủ luồng presigned 3 bước, có
HeadObjectxác minh; ghim size/type vào chữ ký.Đáp án
(1) API kiểm quyền, tạo
PENDING, ký PUT cóContentType/ContentLength; (2) client PUT thẳng lên storage; (3) APIHeadObjectkiểm tồn tại và size rồiREADY. Tự kiểm: confirm khi chưa upload phải 400. Presigned POST chỉ có trên AWS S3, R2 không hỗ trợ;HeadObjectmới là chốt chặn thật. -
Bucket private; không có object nào public-read.
Đáp án
Truy cập chỉ qua presigned URL hoặc credential. Tự kiểm:
curl -IURL object không có chữ ký phải 403; AWS: kiểm Block Public Access vàget-bucket-policy-status. Không có ACL/policy public-read. -
Bật lifecycle abort multipart; giải thích được khoản phí ẩn nếu không bật.
Đáp án
Part của upload dở vẫn chiếm dung lượng tính phí nhưng không hiện trong danh sách object; rule
AbortIncompleteMultipartUpload(ví dụ 7 ngày) dọn chúng. Tự kiểm:get-bucket-lifecycle-configurationthấy rule. Xem mục 8. -
Xử lý nặng chạy trong worker, không trong request handler.
Đáp án
Handler chỉ enqueue và trả
202+PROCESSING; worker resize/trích text/quét. Lý do: CPU-bound chặn event loop của cả server và vượt timeout proxy. -
Có chiến lược chống orphan cả hai chiều (DB-first, xoá mềm, job đối soát).
Đáp án
Ghi DB
PENDINGtrước khi ký URL (orphan DB dễ dọn, orphan S3 vô hình); xoá thì xoá mềm rồi purge object hoãn; cron dọnPENDING> 24 giờ và đối soát key S3 với DB (cảnh báo trước khi xoá tự động). -
Giải thích vì sao multipart hoàn tất bằng
ListPartscủa server thay vì danh sách ETag do client gửi.Đáp án
Client không đáng tin: nó có thể gửi thiếu part, sai thứ tự hoặc part kích thước khác ý định. Server hỏi lại storage (
ListParts, phân trang tối đa 1000 part mỗi trang), kiểm số part, thứ tự vàSizetừng part rồi mớiCompleteMultipartUpload, vàHeadObjectkiểm tổng size. Phụ thêm: client không cần đọc headerETag, nên CORS không phảiExposeHeaders: ["ETag"]trừ khi thư viện client đòi.
-
Tách transactional / marketing bằng subdomain riêng; giải thích được reputation lây chéo.
Đáp án
Transactional do hành động của một người; marketing gửi hàng loạt theo lịch của bạn. Reputation gắn với domain/IP gửi: chiến dịch bị báo spam kéo tụt cả mail reset password nếu dùng chung. Dùng
mail.cho transactional,news.cho marketing, không gửi từ domain gốc. -
Cấu hình SPF + DKIM + DMARC và đọc được header xác thực trong mail đã nhận.
Đáp án
Ba bản ghi DNS ở mục 13; kiểm bằng
dig +short TXT ..., rồi gửi mail thật và đọcAuthentication-Results(hoặc "Show original" của Gmail) thấyspf=pass,dkim=pass,dmarc=pass. Cần domain thật nên đây là bước tự làm ngoài code. -
Phân biệt alignment của SPF với alignment của DKIM, và nói được
adkim,aspf,spđổi điều gì.Đáp án
SPF so domain của
MAIL FROM(Return-Path) vớiFrom:; DKIM so tagd=. DMARC pass khi ít nhất một cơ chế vừa pass vừa aligned.adkimvàaspfchọn relaxed (r, mặc định, cùng Organizational Domain) hay strict (s, trùng chính xác).splà chính sách cho subdomain không có bản ghi DMARC riêng. Tự kiểm: vớiFrom: news@myapp.comvàd=mail.myapp.com,adkim=raligned,adkim=skhông. -
Giải thích DMARC alignment và vì sao DKIM pass vẫn có thể DMARC fail.
Đáp án
DMARC pass khi SPF hoặc DKIM pass và domain xác thực khớp domain trong
From:. Với chế độ relaxed mặc định,d=mail.myapp.comvàFrom: ...@myapp.comvẫn khớp (cùng Organizational Domain). DKIM pass mà DMARC fail xảy ra khid=là domain của provider, hoặc khi bản ghi DMARC đặtadkim=s(strict, phải trùng chính xác). -
Gửi mail qua queue + outbox, có retry và
idempotencyKey.Đáp án
Ghi outbox cùng transaction với dữ liệu, relay đẩy vào queue, worker gửi và tự retry.
idempotencyKeygiúp retry sau timeout không gửi hai mail; thời hạn dedupe là tuỳ provider (đọc tài liệu provider, bài không khẳng định con số). -
Có suppression list từ webhook bounce/complaint, kiểm tra trước mỗi lần gửi.
Đáp án
Webhook hard bounce/complaint ghi địa chỉ vào bảng; worker kiểm bảng trước mỗi lần gửi. Tự kiểm: gửi webhook giả có chữ ký đúng rồi thử gửi mail tới địa chỉ đó, mail bị bỏ qua.
-
Mail luôn có bản
textkèmhtml.Đáp án
HTML-only bị lọc spam chấm điểm cao hơn và một số client chỉ đọc text. Tự kiểm: mail trong Mailpit có cả tab HTML và Text.
-
Chống user enumeration ở reset password; chống header injection.
Đáp án
Reset luôn trả cùng một thông báo và cùng thời gian (đẩy việc sang worker); bỏ
\r/\nkhỏi giá trị đưa vào header, nội dung động chỉ ở body và escape HTML. Test:name = "Hi\r\nBcc: x@y.z"không tạo headerBcc. -
Token trong link: ngẫu nhiên mạnh, hash trước khi lưu, hết hạn ngắn, dùng một lần.
Đáp án
crypto.randomBytes(32)base64url; lưusha256(token); hết hạn 15 đến 60 phút; dùng một lần bằngUPDATE ... WHERE used_at IS NULL AND expires_at > now()kiểm số dòng. Sai thường gặp: lưu token thô (rò DB là reset được mọi tài khoản). -
Mail marketing có
List-Unsubscribe+List-Unsubscribe-Postmột chạm;GETkhông huỷ đăng ký.Đáp án
Một header
List-Unsubscribecó URI https, headerList-Unsubscribe-Post: List-Unsubscribe=One-Click, cả hai trong chữ ký DKIM. EndpointPOSTkhông cookie, không redirect, trả 200 và ghi opt-out;GETchỉ hiện trang xác nhận vì bộ quét link tự gọiGET. Tự kiểm bằng ba lệnhcurlở Thực hành B6.
Câu hỏi mở / chưa giải quyết#
-
S3 hay Cloudflare R2? R2 miễn phí egress (rẻ hơn nhiều nếu người dùng tải nhiều); S3 tích hợp sâu hơn với hệ AWS còn lại. Chốt theo nơi deploy ở GĐ15.
Hướng trả lời hiện tại (chưa chốt)
Giữ client S3 với endpoint cấu hình được để đổi nhà cung cấp sau; chọn S3 hay R2 theo nơi deploy ở GĐ15 và chi phí tải xuống thực tế.
-
Quét virus: ClamAV tự host (tốn RAM, phải cập nhật signature) vs dịch vụ trả phí. Chỉ cần khi người dùng tải file của nhau — cân nhắc theo sản phẩm.
Hướng trả lời hiện tại (chưa chốt)
Chỉ quét virus khi người dùng tải file của nhau; cân nhắc theo sản phẩm.
-
Có nên cho phép upload SVG? Nếu là app thiết kế thì buộc phải — khi đó cần sanitize pipeline riêng, đừng tự viết.
Hướng trả lời hiện tại (chưa chốt)
Không nhận SVG cho tới khi có nhu cầu thật; khi có thì dùng thư viện sanitize có sẵn, không tự viết.
-
Provider email: Resend (DX tốt, mới) vs Postmark (deliverability transactional tốt nhất, đắt hơn) vs SES (rẻ nhất, vận hành thủ công nhiều). Quyết khi biết khối lượng gửi thật.
Hướng trả lời hiện tại (chưa chốt)
Giữ
EmailPortđể đổi provider; chọn provider sau khi đo khối lượng gửi và tỉ lệ bounce thực tế.