Tự host hộp thư email tạm thời trên VPS
Dùng Mailpit trên VPS để hứng mọi email test, xem trong web UI và ngăn app staging gửi nhầm email thật cho khách hàng. Có Docker Compose, TLS và password.
Hộp thư email tạm thời là gì
Hộp thư email tạm thời là một máy chủ SMTP (simple mail transfer protocol) nhỏ, chấp nhận email gửi đến mọi địa chỉ nhưng không chuyển tiếp email nào. Ứng dụng staging của bạn gửi email đến đó thay vì nhà cung cấp email thật, và mọi message đều dừng lại tại đó. Bạn đọc các message đã nhận trong web interface. Vì email không bao giờ rời khỏi hộp thư nên danh sách người nhận sai hoặc template bị lỗi cũng không gây thiệt hại.
Hướng dẫn này dựng một hộp thư trên một VPS duy nhất bằng Docker Compose. Mailpit đóng vai trò sink nhận mọi địa chỉ. SMTP listener của Mailpit chỉ bind tại nơi ứng dụng của bạn có thể truy cập. Web interface chạy phía sau nginx với transport layer security (TLS) và password. Giới hạn retention ngăn mailbox làm đầy disk. Nếu Compose còn mới với bạn, phần cơ bản về Compose cho VPS trình bày layout file mà hướng dẫn này sử dụng.
Kết quả là một công cụ test, không phải mail server. Công cụ này không có account, không chuyển phát email và không lọc spam. Mailbox thật cho người dùng thật là một mail server đầy đủ như Mailcow, và đó là một công việc lớn hơn nhiều.
Mailpit vs Inbucket vs MailHog: nên chạy sink nào
Có 3 công cụ thực hiện việc này. Điểm khác biệt nằm ở tình trạng bảo trì, các cổng chúng lắng nghe và cách xử lý message sau khi nhận. Các phiên bản dưới đây được kiểm tra vào tháng 8 năm 2026.
MailHog (mailhog/mailhog) lắng nghe SMTP trên cổng 1025 và cung cấp giao diện trên cổng 8025. Công cụ này vẫn hoạt động. Nhánh mặc định không có commit nào từ tháng 8 năm 2022 và tracker có hơn 250 issue đang mở, nên bạn sẽ chạy các dependency chưa được vá trong luồng test. Không nên bắt đầu công việc mới với công cụ này.
Inbucket (inbucket/inbucket) lắng nghe SMTP trên cổng 2500, giao diện web trên cổng 9000 và POP3 (giao thức bưu điện phiên bản 3) trên cổng 1100. Version 3.1.1 được release vào tháng 12 năm 2025. Công cụ này lưu message dưới dạng file trong /storage và tự prune chúng: image đặt INBUCKET_STORAGE_RETENTIONPERIOD=72h và INBUCKET_STORAGE_MAILBOXMSGCAP=300. Chọn Inbucket khi test cần thu thập mail bằng thư viện client POP3 thay vì gọi HTTP.
Mailpit (axllent/mailpit) dùng cùng các cổng với MailHog, 1025 và 8025, nên có thể thay MailHog mà không cần sửa config của ứng dụng. Version 1.30.7 được release vào ngày 8 tháng 8 năm 2026. Binary đã có sẵn các tính năng mà guide này cần: file mật khẩu cho giao diện web và API (application programming interface), giới hạn số message, giới hạn tuổi message và bộ lọc recipient. Phần còn lại của guide này chạy Mailpit.
Cách catch-all hoạt động và vì sao DNS không liên quan
Ứng dụng của bạn không tra cứu nơi cần gửi thư. Bạn cung cấp cho ứng dụng một host và một port, ứng dụng mở kết nối TCP rồi thông báo RCPT TO:<anyone@example.test>. Mailpit chấp nhận recipient đó với bất kỳ nội dung nào, lưu message và không chuyển tiếp đi đâu. Domain không bao giờ được resolve, nên example.test vẫn hoạt động dù .test là một tên được dành riêng và không tồn tại ở đâu trong hệ thống tên miền (DNS).
Đó là toàn bộ cơ chế. Vì vậy inbox mặc định đã an toàn. Không có bản ghi MX (mail exchanger), không thực hiện gửi thư và không message nào có thể đến được người thật.
Trỏ ứng dụng staging đến sink
Đặt SMTP host của ứng dụng thành mailpit khi ứng dụng chạy dưới dạng container trong cùng Compose project, hoặc thành 127.0.0.1 khi ứng dụng chạy trên host. Đặt port là 1025, tắt TLS và để trống username cùng password. Mailpit chấp nhận mail ẩn danh.
Một số framework không gửi mail nếu thiếu credential. MP_SMTP_AUTH_ACCEPT_ANY=1 khiến Mailpit chấp nhận mọi username và password, còn MP_SMTP_AUTH_ALLOW_INSECURE=1 cho phép cơ chế PLAIN và LOGIN trên kết nối không mã hóa. Hai thiết lập này chỉ an toàn ở đây vì listener không thể truy cập từ Internet; phần triển khai bên dưới sẽ đảm bảo điều đó.
Nên thiết lập MP_SMTP_ALLOWED_RECIPIENTS ngay từ đầu. Tùy chọn này nhận một regular expression và từ chối mọi recipient không khớp. Trỏ nó đến test domain của bạn. Khi đó, nếu staging database vẫn chứa địa chỉ email thật của khách hàng, ứng dụng sẽ ghi lỗi rõ ràng vào log thay vì âm thầm đưa mail vào sink.
Tệp Docker Compose
Trước tiên, tạo thư mục và file mật khẩu cho web interface. htpasswd -B ghi hash bcrypt, và Mailpit cũng đọc được cả bcrypt lẫn plain text.
mkdir -p ~/mailpit/data
cd ~/mailpit
sudo apt update && sudo apt install -y apache2-utils
htpasswd -B -c data/ui-auth qaGhi compose.yaml:
services:
mailpit:
image: axllent/mailpit:v1.30
container_name: mailpit
restart: unless-stopped
ports:
- "127.0.0.1:8025:8025"
- "127.0.0.1:1025:1025"
volumes:
- ./data:/data
environment:
MP_DATABASE: /data/mailpit.db
MP_MAX_MESSAGES: 2000
MP_MAX_AGE: 14d
MP_UI_AUTH_FILE: /data/ui-auth
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
MP_SMTP_ALLOWED_RECIPIENTS: '@example\.test$$'Hai dấu dollar liên tiếp không phải lỗi gõ. Compose đọc một $ đơn như phần bắt đầu của biến cần expand, nên $$ là cách truyền một dollar literal vào container. Regex đến Mailpit dưới dạng @example\.test$.
Khởi động và kiểm tra trạng thái health:
docker compose up -d
docker compose psCột STATUS phải hiển thị Up ... (healthy). Image có sẵn healthcheck, chạy /mailpit readyz mỗi 15 giây. Vì vậy, nếu container vẫn ở trạng thái starting hoặc chuyển sang unhealthy, dịch vụ không phục vụ trên cổng 8025 bên trong container. Đọc docker compose logs mailpit trước khi thay đổi bất kỳ thứ gì khác.
Cả hai cổng được publish đều có một địa chỉ bind, và địa chỉ đó là control bảo mật. Bên trong container, Mailpit lắng nghe trên 0.0.0.0. Điều này không có vấn đề vì container có network namespace riêng. Vế bên trái của mapping quyết định bên ngoài có thể truy cập nó từ đâu. Ghi 8025:8025 thì Docker sẽ bind mọi địa chỉ trên host, bao gồm cả địa chỉ public.
Nếu staging app của bạn là một service trong cùng file này, hãy xóa hoàn toàn mapping 1025 và trỏ app đến hostname mailpit trên cổng 1025. Các container trong cùng một Compose network có thể truy cập trực tiếp lẫn nhau, nên cổng SMTP không cần đi qua host. Cách Compose phân giải tên service giải thích cơ chế lookup đó.
Gửi một message và kiểm tra message đã được lưu
python3 - <<'EOF'
import smtplib
from email.message import EmailMessage
m = EmailMessage()
m["From"] = "staging@example.test"
m["To"] = "anyone@example.test"
m["Subject"] = "Mailpit smoke test"
m.set_content("If this appears in the web interface, the sink works.")
with smtplib.SMTP("127.0.0.1", 1025) as s:
s.send_message(m)
EOFScript không in ra gì khi chạy thành công. Xác nhận message đã được lưu qua API:
curl -s -u qa:yourpassword http://127.0.0.1:8025/api/v1/messagesLệnh này trả về JSON chứa danh sách các message đã lưu. Bỏ flag -u thì request tương tự sẽ bị từ chối, vì MP_UI_AUTH_FILE bảo vệ cả API và web interface. Mọi bài kiểm tra đọc inbox cũng phải gửi kèm các thông tin xác thực đó.
ConnectionRefusedError từ Python script có nghĩa là không có tiến trình nào đang listen trên 127.0.0.1:1025. Đây là kết quả đúng nếu bạn đã xóa mapping SMTP. Khi đó, bài kiểm tra phải chạy từ một container trên cùng Compose network.
Publish giao diện web qua nginx với mật khẩu
Giao diện hiện chỉ phản hồi trên địa chỉ loopback. nginx thực hiện TLS termination và yêu cầu mật khẩu trước khi chuyển bất kỳ request nào đến giao diện.
sudo htpasswd -B -c /etc/nginx/mailpit.htpasswd qaserver {
listen 443 ssl;
server_name mail-test.example.com;
ssl_certificate /etc/letsencrypt/live/mail-test.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mail-test.example.com/privkey.pem;
auth_basic "mailpit";
auth_basic_user_file /etc/nginx/mailpit.htpasswd;
location / {
proxy_pass http://127.0.0.1:8025;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Sau khi kiểm tra cú pháp, hãy reload bằng sudo nginx -t && sudo systemctl reload nginx. Mỗi directive trong một block reverse proxy có tác dụng gì là phần nên đọc một lần nếu đây là lần đầu bạn cấu hình proxy.
Dùng cùng một username và password trong file nginx và trong data/ui-auth. nginx chuyển tiếp header Authorization của trình duyệt đến upstream, nên thông tin xác thực khớp sẽ đáp ứng cả hai bước kiểm tra bằng một prompt duy nhất. Nếu dùng thông tin xác thực khác nhau, trình duyệt sẽ lưu một bộ thông tin nhưng bước kiểm tra thứ hai sẽ từ chối bộ đó.
Các header Upgrade và Connection không phải phần trang trí. Mailpit đẩy mail mới đến trang đang mở qua WebSocket, và proxy chạy HTTP/1.1 nhưng thiếu các header này sẽ không thể upgrade connection. Khi đó trang vẫn tải được nhưng không cập nhật: mail đến, API hiển thị mail, còn danh sách đứng yên cho đến khi bạn reload.
Hãy giữ cả hai lớp bảo vệ. Mật khẩu nginx bảo vệ địa chỉ public, còn MP_UI_AUTH_FILE bảo vệ trực tiếp port 8025. Điều này quan trọng vì mọi password reset link mà staging app của bạn từng tạo đều có thể đọc được trong giao diện đó.
Không bao giờ để sink trở thành open relay
Open relay là SMTP server nhận message từ bất kỳ ai rồi chuyển tiếp đến bất kỳ đích nào. Spammer liên tục quét để tìm các server này. Nếu tìm thấy một server trên địa chỉ của bạn, họ có thể gửi báo cáo abuse và tài khoản của bạn có thể bị đình chỉ.
Mailpit không phải là open relay theo mặc định vì nó không bao giờ chuyển tiếp message. Tính năng relay vẫn tắt cho đến khi bạn trỏ MP_SMTP_RELAY_CONFIG đến một file cấu hình relay. Nút release trong giao diện cũng không làm gì cho đến khi bạn thực hiện việc này. Để trống thiết lập đó là lựa chọn có chủ ý.
Có hai cách làm mất tính chất này. Nếu bạn cấu hình relay để nút release hoạt động rồi expose SMTP port ra internet, bạn đã tạo ra một open relay thực sự. Nếu expose port nhưng không cấu hình relay, người lạ không thể gửi mail qua server của bạn. Tuy nhiên, họ vẫn có thể làm đầy storage và đưa nội dung vào giao diện mà team của bạn tin cậy.
Bẫy trên Docker host là firewall. Khi publish một port, Docker tự ghi rule vào bảng nat. Traffic đến container được match tại đó trước khi rule của ufw (uncomplicated firewall) có cơ hội xử lý. sudo ufw deny 1025/tcp báo thành công nhưng không thay đổi gì. Vì sao Docker publish port vượt qua ufw giải thích thứ tự của chuỗi xử lý.
Cách sửa nằm ở địa chỉ trong mapping, không phải ở firewall rule. Kiểm tra địa chỉ thực sự đang được bind:
sudo ss -ltnp | grep -E ':(1025|8025)'Output đúng cho thấy 127.0.0.1:1025 và 127.0.0.1:8025. Nếu có dòng 0.0.0.0:1025, mapping đã mất địa chỉ và sink đang listen trên internet. Từ một máy khác, nc -vz mail-test.example.com 1025 phải timeout hoặc bị từ chối.
Khi application chạy trên server khác, không mở 1025 để kết nối hai máy. Đưa cả hai máy vào private network hoặc VPN tunnel, rồi bind mapping vào địa chỉ của interface đó.
Chỉ công bố bản ghi MX khi bạn thực sự muốn nhận mail từ Internet
Bản ghi MX (mail exchanger) cho các mail server khác biết host nào nhận mail cho một domain. Nếu domain dùng tạm của bạn không có bản ghi MX, không mail nào từ Internet có thể đến được, vì các server gửi không có nơi để chuyển mail. Inbox chỉ chứa những gì ứng dụng của bạn tự gửi, đúng với mục đích của một test mailbox.
Nhận mail thực nghĩa là có bản ghi MX trỏ đến máy chủ, Mailpit đang listen trên port 25 (MP_SMTP_BIND_ADDR=0.0.0.0:25), và port đó được mở. Khi đó, bạn đang vận hành một public catch-all cho mọi địa chỉ trong domain. Hãy hiểu rõ các hệ quả sau:
- Spam sẽ bắt đầu xuất hiện trong vài ngày sau khi bản ghi được công bố, vì các công cụ harvest địa chỉ đọc DNS. Sau đó, các cuộc tấn công dictionary sẽ lần lượt thử những tên phổ biến và lưu một message cho mỗi lần thử.
- Attachment từ người lạ sẽ được ghi vào disk và tồn tại ở đó. Không có gì filter chúng, nên archive từ một sender không xác định sẽ nằm cạnh mail test của bạn.
- Bất kỳ ai biết domain đều có thể đăng ký dịch vụ bên thứ ba bằng một địa chỉ thuộc domain đó, và mail xác nhận sẽ được chuyển đến server của bạn. Nếu lớp bảo vệ bằng password từng bị vượt qua, các account đó sẽ thuộc về người đang đọc inbox.
- Giới hạn retention không còn chỉ là việc dọn dẹp định kỳ mà trở thành yếu tố quyết định tải của hệ thống, vì bạn không còn kiểm soát được volume mail.
Nếu cần nhận mail thực để kiểm tra deliverability, hãy dành riêng một subdomain cho việc đó, giữ MP_MAX_AGE ở mức ngắn, và coi mọi thứ trong đó là public. Nếu cần các mailbox mà người khác thực sự phụ thuộc vào, hãy chạy một mail server đúng nghĩa với filtering và backup.
Retention: catch-all không giới hạn làm đầy disk
Theo mặc định, Mailpit giữ 500 message rồi định kỳ xóa những message cũ hơn số đó. MP_MAX_MESSAGES: 0 tắt hoàn toàn việc xóa tự động. Chỉ một thay đổi này cũng đủ khiến catch-all làm đầy disk mà không ai nhận ra. MP_MAX_AGE thêm giới hạn thời gian và cần vài giờ hoặc vài ngày để có hiệu lực, được ghi là 36h hoặc 14d.
MP_DATABASE quyết định dữ liệu có được giữ lại hay không. Nếu không có tùy chọn này, Mailpit ghi vào một file tạm. File đó bị xóa khi process thoát, nên mỗi lần restart đều làm trống inbox. Khi có tùy chọn này, mail vẫn còn sau khi restart và file sẽ tiếp tục tăng kích thước.
Attachment là phần chiếm dung lượng. Một job chạy hằng đêm gửi report PDF 2 MB đến 300 địa chỉ test sẽ tạo ra 600 MB mỗi đêm. Chỉ giới hạn số message sẽ không xử lý kịp. Hãy tính mức tăng này cùng với các dữ liệu khác dùng chung volume. Một dịch vụ xử lý media như PhotoPrism hoặc Immich có thể đã chiếm phần lớn disk của một VPS nhỏ.
du -h ~/mailpit/data/mailpit.db
df -h /Hãy xóa store giữa các lần chạy CI thay vì chờ giới hạn kích hoạt:
curl -s -u qa:yourpassword -X DELETE http://127.0.0.1:8025/api/v1/messagesInbucket xử lý vấn đề tương tự bằng INBUCKET_STORAGE_RETENTIONPERIOD (72h trong image) và INBUCKET_STORAGE_MAILBOXMSGCAP (300). Dù bạn chạy dịch vụ nào, hãy chọn giới hạn trước khi test suite đầu tiên trỏ đến dịch vụ đó.
Đọc hộp thư từ test suite của bạn
GET /api/v1/messages liệt kê nội dung đang được lưu trữ, GET /api/v1/message/{ID} trả về một message cùng các phần và header của nó, GET /api/v1/search dùng để lọc, còn DELETE /api/v1/messages xóa toàn bộ dữ liệu lưu trữ. Tài liệu tương tác cho phiên bản bạn đang chạy có tại http://127.0.0.1:8025/api/v1/.
Một bài test hữu ích sẽ gửi một message, polling cho đến khi message xuất hiện, kiểm tra subject và link bên trong, rồi xóa toàn bộ dữ liệu. Hãy polling trong một vòng lặp retry ngắn thay vì chỉ gửi một request, vì application đưa mail vào hàng đợi bằng background worker sẽ trả về từ lệnh gửi trước khi Mailpit nhận được message. Mẫu này cũng xuất hiện trong các công cụ tự host để test và mock API, thường là nửa còn lại của một staging environment không bao giờ chạm tới production.
FAQ
Inbox email tạm thời tự host có phải là open relay không?
Không, miễn là relay vẫn được tắt. Mailpit lưu message và không forward chúng cho đến khi bạn trỏ MP_SMTP_RELAY_CONFIG đến một cấu hình relay. Vì vậy, người lạ truy cập được port 1025 cũng không thể gửi mail thông qua server của bạn. Tuy nhiên, họ vẫn có thể làm đầy storage. Do đó, hãy bind SMTP port vào một địa chỉ mà chỉ application của bạn truy cập được. Khai báo 1025:1025 trong Compose sẽ bind vào mọi địa chỉ của host. sudo ufw deny 1025/tcp sẽ không đóng port này, vì các nat rule của Docker được match trước.
Tôi có cần MX record cho test domain không?
Chỉ khi bạn muốn nhận mail từ Internet. Không có MX record, các server gửi mail không biết phải deliver đến đâu. Vì vậy, inbox chỉ chứa message do các application của bạn submit qua SMTP. Nếu publish record và mở port 25, bạn đang chạy một public catch-all: trong vài ngày sẽ có spam, dictionary attack lưu một message cho mỗi lần thử, và attachment từ người lạ nằm trên disk mà không được filtering.
Tại sao danh sách message chỉ cập nhật khi tôi reload page?
Mailpit push mail mới đến page đang mở qua WebSocket. Một nginx location block thiếu proxy_http_version 1.1 cùng các header Upgrade và Connection sẽ không thể upgrade connection đó. Vì vậy, page vẫn load bình thường rồi bị treo. Mail vẫn đến và API vẫn trả về message. Do đó, inbox trông như bị stale thay vì bị hỏng. Thêm các dòng đó, reload nginx, rồi reload page.
Làm thế nào để ngăn inbox làm đầy disk?
Giữ MP_MAX_MESSAGES ở một giá trị thực và thêm MP_MAX_AGE. Giới hạn mặc định là 500 message. Đặt giá trị này thành 0 sẽ tắt hoàn toàn việc xóa message. Đây là nguyên nhân catch-all có attachment âm thầm làm đầy disk. MP_MAX_AGE nhận giá trị tính theo giờ hoặc ngày, chẳng hạn 36h hoặc 14d. Xóa store trong bước CI teardown bằng curl -X DELETE http://127.0.0.1:8025/api/v1/messages. Inbucket cũng có chức năng tương tự với INBUCKET_STORAGE_RETENTIONPERIOD (72h) và INBUCKET_STORAGE_MAILBOXMSGCAP (300).
Nên chạy Mailpit, Inbucket hay MailHog?
Chọn Mailpit cho công việc mới, tính đến August 2026. MailHog vẫn chạy được, nhưng branch mặc định của nó không có commit nào kể từ August 2022. Vì vậy, nó phát hành kèm các dependency chưa được vá. Inbucket vẫn được duy trì tích cực (3.1.1, December 2025) và là lựa chọn tốt hơn khi test cần POP3, vì POP3 server của Mailpit chỉ khởi động sau khi bạn cung cấp password file. Mailpit dùng cùng port với MailHog là 1025 và 8025. Vì vậy, thay MailHog chỉ cần đổi tên image trong file Compose.