SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor

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 và Inbucket và MailHog: nên chạy sink nào

Có 3 công cụ thực hiện công việc này. Điểm khác nhau là tình trạng maintenance, các cổng chúng listening và khả năng xử lý message sau khi nhận. Các version dưới đây được kiểm tra vào tháng 10 năm 2026.

MailHog (mailhog/mailhog) listening trên cổng 1025 cho SMTP và cung cấp interface trên cổng 8025. Nó vẫn hoạt động. Branch mặc định không có commit nào từ tháng 8 năm 2022 và tracker có hơn 200 issue đang mở, vì vậy bạn sẽ chạy các dependency chưa được patch trong luồng test. Không nên bắt đầu công việc mới với nó.

Inbucket (inbucket/inbucket) listening trên cổng 2500 cho SMTP, cổng 9000 cho web interface và cổng 1100 cho POP3 (post office protocol version 3). Version 3.2.0 được release vào ngày 10 tháng 10 năm 2026. Nó 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 nó khi test cần thu thập mail bằng POP3 client library 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 application config. Version 1.31.5 được release vào ngày 11 tháng 10 năm 2026; dòng 1.30 kết thúc ở version 1.30.7 vào tháng 8. Nó tích hợp sẵn trong binary những tính năng mà hướng dẫn này cần: password file cho web interface 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 hướng dẫn 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 password cho web interface. htpasswd -B ghi hash bcrypt, và Mailpit cũng đọ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 qa

Ghi compose.yaml:

services:
  mailpit:
    image: axllent/mailpit:v1.31
    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 đánh máy. 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 dấu 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 ps

Cột STATUS phải hiển thị Up ... (healthy). Image có sẵn healthcheck, chạy /mailpit readyz mỗi 15 giây, nên container giữ trạng thái starting hoặc chuyển sang unhealthy thường là do không phục vụ trên cổng 8025 bên trong container. Check này lấy cấu hình từ các biến môi trường MP_ nhưng không lấy từ command-line flags. Đây là một lý do guide này đặt mọi thứ dưới environment:. Nếu truyền option bằng flags, check có thể báo unhealthy trong khi Mailpit vẫn phục vụ bình thường. Đọc docker compose logs mailpit trước khi thay đổi bất kỳ thứ gì khác.

Cả hai published port đều có một địa chỉ bind, và địa chỉ đó là cơ chế kiểm soát bảo mật. Bên trong container, Mailpit lắng nghe trên 0.0.0.0. Điều này không vấn đề vì container có network namespace riêng. Vế bên trái của mapping quyết định ai có thể truy cập từ bên ngoài. Ghi 8025:8025 sẽ khiến Docker bind trên mọi địa chỉ của 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 port 1025. Các container trong cùng Compose network có thể truy cập trực tiếp lẫn nhau, nên SMTP port hoàn toàn không chạm vào host. Cách Compose phân giải tên service trình bày cách lookup này.

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)
EOF

Script 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/messages

Lệ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.

Công khai giao diện web qua nginx với mật khẩu

Hiện tại, giao diệ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 qa
server {
    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. Tìm hiểu chức năng của từng directive trong block reverse proxy là nội dung 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 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 nhau sẽ đáp ứng cả hai bước kiểm tra chỉ với một prompt. Nếu dùng thông tin xác thực khác nhau, trình duyệt sẽ giữ 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 để trang trí. Mailpit đẩy mail mới đến trang đang mở qua WebSocket, còn 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 đúng nhưng không cập nhật: mail đến, API hiển thị mail, còn danh sách không thay đổi 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 đã tạo sẽ có thể đọc được trong giao diện đó trong suốt thời gian retention lưu giữ chúng.

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 abuse report khiến tài khoản bị đình chỉ.

Mailpit không phải là open relay ngay sau khi cài đặt, vì mặc định nó không gửi message đi đâu cả. Nó có 2 tính năng outbound và cả 2 đều tắt cho đến khi bạn cấu hình. MP_SMTP_RELAY_CONFIG trỏ đến một relay configuration file và bật release action trong interface. Action này gửi message bạn chọn qua server đó. Để relay tự động, cần bật thêm MP_SMTP_RELAY_ALL (mặc định là false) hoặc MP_SMTP_RELAY_MATCHING. Forwarding, được cấu hình bằng MP_SMTP_FORWARD_CONFIG hoặc các biến MP_SMTP_FORWARD_*, sao chép mọi message nhận được đến các địa chỉ cố định do bạn chọn. Để tất cả các tùy chọn này ở trạng thái unset là một lựa chọn có chủ đích.

Có 2 cách làm mất đặc tính này. Bật MP_SMTP_RELAY_ALL, tùy chọn này gửi mọi message nhận được đến các recipient ban đầu và bỏ qua MP_SMTP_ALLOWED_RECIPIENTS, sau đó expose SMTP port ra Internet, và bạn đã tạo ra một open relay hoạt động. MP_SMTP_RELAY_MATCHING cũng làm tương tự với mọi recipient khớp pattern của nó. Nếu expose port mà không bật relay, người lạ không thể gửi mail qua server của bạn, nhưng họ vẫn có thể làm đầy storage và đưa nội dung vào interface mà team của bạn tin cậy. Khi bật forwarding, message của họ cũng sẽ được chuyển đến inbox nhận các bản sao.

Bẫy trên Docker host nằm ở firewall. Khi publish một port, Docker tự ghi rule vào nat table. Traffic đi đến container được match tại đó trước khi rule của ufw (uncomplicated firewall) có hiệu lực. sudo ufw deny 1025/tcp báo thành công nhưng không thay đổi gì. Vì sao Docker publish port đi thẳng qua ufw giải thích thứ tự của chain.

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 nằm trên server khác, không mở 1025 để kết nối 2 máy. Đặt cả 2 máy trong một 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 muốn nhận mail thực

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. Không khai báo bản ghi này không tự đóng đường nhận mail: khi domain không có bản ghi MX, các server gửi sẽ dùng bản ghi địa chỉ (A hoặc AAAA) của domain theo RFC 5321 mục 5.1, và hostname bạn trỏ đến box này cho web interface đã có bản ghi đó. Điều ngăn mail từ Internet đi vào là trên box không có tiến trình nào listening trên port 25. Inbox chỉ chứa những gì các ứng dụng của bạn tự gửi vào, đúng với mục đích của một test mailbox.

Nhận mail thực cần có một bản ghi DNS trỏ các server gửi đến box (bản ghi MX hoặc bản ghi địa chỉ mà chúng dùng khi fallback), Mailpit listening trên port 25 (MP_SMTP_BIND_ADDR=0.0.0.0:25), và port đó phải được mở. Khi đó, bạn đang vận hành một catch-all public 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 harvester đọc DNS. Sau đó, các cuộc tấn công kiểu dictionary sẽ lần lượt thử những tên phổ biến và lưu lại một message cho mỗi lần thử.
  • Attachment từ người lạ sẽ được ghi xuống disk và nằm lại ở đó. Không có gì lọc chúng, nên một archive 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 gửi đến server của bạn. Nếu password gate có lúc 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 housekeeping mà trở thành yếu tố chịu tải chính, vì volume không còn nằm trong tầm kiểm soát của bạn.

Nếu cần nhận mail thực để kiểm tra deliverability, hãy cấp cho nó một subdomain riêng, 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 dùng thực sự phụ thuộc vào, hãy chạy một mail server đúng nghĩa, có filtering và backup.

Retention: cách catch-all không giới hạn làm đầy disk

Mailpit giữ lại 500 message theo mặc định và định kỳ xóa những message cũ hơn số này. MP_MAX_MESSAGES: 0 tắt giới hạn đó. Khi không đặt MP_MAX_AGE, không có message nào tự động bị xóa. Đây là nguyên nhân catch-all có thể làm đầy disk mà không ai phát hiện. MP_MAX_AGE thêm giới hạn thời gian. Tùy chọn này có thể dùng cùng hoặc thay cho giới hạn số message. Giá trị được tính theo giờ hoặc ngày, viết dưới dạng 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 và file bị xóa khi process thoát. Vì vậy, mỗi lần restart đều làm trống inbox. Khi có tùy chọn này, mail vẫn tồn tại sau restart và file sẽ tiếp tục tăng dung lượng.

Attachment là phần chiếm dung lượng chính. 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 mức tăng này. Hãy tính mức tăng đó cùng với các dữ liệu khác đang dùng chung volume. Một dịch vụ lân cận cần nhiều media như PhotoPrism hoặc Immich có thể đã chiếm gần hết disk của một VPS nhỏ.

du -h ~/mailpit/data/mailpit.db
df -h /

Xóa store giữa các lần chạy CI thay vì chờ giới hạn được kích hoạt:

curl -s -u qa:yourpassword -X DELETE http://127.0.0.1:8025/api/v1/messages

Inbucket xử lý vấn đề tương tự bằng INBUCKET_STORAGE_RETENTIONPERIOD (72h trong image) và INBUCKET_STORAGE_MAILBOXMSGCAP (300). Dù chạy công cụ nào, hãy đặt giới hạn trước khi test suite đầu tiên trỏ đến đó.

Đọ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 dùng một lần tự host có phải là open relay không?

Không, miễn là relay vẫn được tắt. Theo mặc định, Mailpit không gửi tiếp thư nào: MP_SMTP_RELAY_CONFIG chỉ bật việc phát hành thủ công các thư bạn chọn, còn relay tự động cần thêm MP_SMTP_RELAY_ALL hoặc MP_SMTP_RELAY_MATCHING. Để các biến này unset, người lạ truy cập được cổng 1025 cũng không thể gửi thư qua server của bạn. Tuy nhiên, họ vẫn có thể làm đầy storage, vì vậy hãy bind cổng SMTP vào địa chỉ mà chỉ application của bạn truy cập được. Khai báo cổng này là 1025:1025 trong Compose sẽ bind vào mọi địa chỉ của host, và sudo ufw deny 1025/tcp cũng không chặn được, vì các rule nat của Docker được match trước.

Tôi có cần bản ghi MX cho domain test không?

Chỉ khi bạn muốn nhận thư từ Internet. Việc không có bản ghi MX tự nó không phải là biện pháp khóa truy cập. Khi không có bản ghi MX, các server gửi thư sẽ fallback về bản ghi A hoặc AAAA của domain. Vì vậy, điều thực sự ngăn thư từ Internet là không có tiến trình nào listen trên cổng 25. Khi đó, inbox chỉ chứa thư do các application của bạn submit qua SMTP. Nếu trỏ DNS đến máy này và mở cổng 25, bạn đang chạy một public catch-all: chỉ vài ngày là có spam, dictionary attack lưu một message cho mỗi lần thử, cùng attachment từ người lạ nằm trên disk mà không qua filtering.

Tại sao danh sách message chỉ cập nhật khi tôi reload trang?

Mailpit push thư mới đến trang đang mở qua WebSocket. Một location block của nginx thiếu proxy_http_version 1.1 cùng các header Upgrade và Connection sẽ không thể upgrade connection đó. Vì vậy, trang vẫn tải bình thường nhưng sau đó bị treo. Thư vẫn đến và API vẫn trả về dữ liệu, nên inbox trông như bị cũ thay vì bị hỏng. Thêm các dòng đó, reload nginx, rồi reload trang.

Làm thế nào để ngăn inbox làm đầy disk?

Giữ MP_MAX_MESSAGES ở một giá trị thực tế 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 việc xóa theo số lượng. Vì vậy, nếu không đặt MP_MAX_AGE thì không có gì bị prune. Đây là cách một catch-all có attachment âm thầm tăng kích thước. MP_MAX_AGE chấp nhận số giờ hoặc số ngày, chẳng hạn 36h hoặc 14d. Xóa store trong bước CI teardown bằng curl -u qa:yourpassword -X DELETE http://127.0.0.1:8025/api/v1/messages, vì API yêu cầu cùng credentials như web interface. Inbucket thực hiện chức năng tương tự với INBUCKET_STORAGE_RETENTIONPERIOD (72h) và INBUCKET_STORAGE_MAILBOXMSGCAP (300).

Tôi nên chạy Mailpit, Inbucket hay MailHog?

Chọn Mailpit cho công việc mới, tính đến tháng 10 năm 2026. MailHog vẫn chạy được, nhưng branch mặc định của nó không có commit nào từ tháng 8 năm 2022, nên chứa các dependency chưa được vá. Inbucket vẫn được duy trì tích cực (3.2.0, tháng 10 năm 2026) 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 credentials, trong password file hoặc qua biến MP_POP3_AUTH. Mailpit dùng cùng các cổng với MailHog, 1025 và 8025, nên thay MailHog chỉ cần đổi tên image trong file Compose.