DEV Community

EME GUG
EME GUG

Posted on

Outlook Is Rejecting Your Email? A Practical SPF, DKIM and DMARC Rollout Guide for Small Teams

Tuần này, một bài viết về việc Outlook.com âm thầm từ chối email từ các domain không có DMARC đang được bàn luận sôi nổi trên Hacker News. Vấn đề này không hề mới: từ năm 2024, Gmail và Yahoo đã siết yêu cầu với bulk sender, và năm 2025 Microsoft cũng công bố chính sách tương tự cho các domain gửi trên 5.000 email/ngày. Điều nguy hiểm là triệu chứng thường rất mơ hồ: app vẫn gọi SMTP thành công, dashboard vẫn xanh, nhưng khách hàng dùng Hotmail lại báo không nhận được email reset password. Bài này là quy trình mình hay dùng để bật DMARC cho team nhỏ mà không làm gãy luồng email đang chạy.

SPF, DKIM, DMARC: mỗi thứ giải quyết một việc

Nhiều dev nghĩ có SPF là đủ. Thực ra ba cơ chế này bổ sung cho nhau:

  • SPF: khai báo server nào được phép gửi thư cho domain trong MAIL FROM (envelope sender).
  • DKIM: ký số lên header và body, chứng minh email không bị sửa trên đường đi và gắn nó với domain trong d=.
  • DMARC: kiểm tra alignment, tức domain trong header From: mà người dùng nhìn thấy có khớp với domain đã pass SPF hoặc DKIM không, rồi chỉ định cách xử lý khi không khớp.

Alignment là chỗ hay bị bỏ sót nhất. Ví dụ, bạn gửi qua Amazon SES với From: no-reply@example.com, nhưng MAIL FROM mặc định là một subdomain của amazonses.com. SPF vẫn pass, nhưng cho domain của SES, nên không align với example.com. Nếu DKIM cũng chưa cấu hình cho domain của bạn thì DMARC fail.

flowchart TD
    A[Mail server nhận email] --> B{SPF pass và align với From?}
    B -->|Có| E[DMARC pass]
    B -->|Không| C{DKIM pass và align với From?}
    C -->|Có| E
    C -->|Không| D[DMARC fail]
    D --> F{Policy của domain}
    F -->|none| G[Xử lý bình thường, ghi vào report]
    F -->|quarantine| H[Đưa vào Junk]
    F -->|reject| I[Từ chối email]

Chỉ cần một trong hai (SPF hoặc DKIM) pass và align là DMARC pass. Dù vậy, nên làm cả hai, vì SPF thường gãy khi email bị forward, còn DKIM thì thường sống sót nếu nội dung không bị sửa.

Bước 1: Kiểm tra hiện trạng và kiểm kê nguồn gửi

Trước khi đụng vào DNS, xem domain đang có gì. Thay selector1 bằng selector thật của bạn: Google Workspace mặc định là google, Microsoft 365 là selector1 và selector2.

DOMAIN=example.com

# SPF: mỗi domain chỉ được có MỘT record v=spf1
dig +short TXT "$DOMAIN" | grep -i "v=spf1"

# DMARC
dig +short TXT "_dmarc.$DOMAIN"

# DKIM
dig +short TXT "selector1._domainkey.$DOMAIN"

# Xem MX để biết bên nhận thư của domain là ai
dig +short MX "$DOMAIN"
Enter fullscreen mode Exit fullscreen mode

Sau đó liệt kê mọi hệ thống đang gửi email bằng domain của bạn: mail công ty, transactional email của app, công cụ marketing, CRM, hệ thống helpdesk, cả con cron job cũ trên VPS mà không ai nhớ. Bước này gần như luôn lòi ra vài nguồn bất ngờ. Không biết hết cũng không sao, vì DMARC report sẽ giúp bạn phát hiện phần còn lại.

Ba điểm hay gặp với SPF:

  • Giới hạn 10 DNS lookup: mỗi include, a, mx đều tính, và include lồng nhau cũng tính. Vượt giới hạn sẽ gây permerror, và SPF coi như fail.
  • Hai record SPF trên cùng domain cũng là permerror. Hãy gộp chúng lại.
  • DKIM key: dùng RSA 2048-bit nếu provider hỗ trợ, đừng dùng 1024-bit nữa.

Bước 2: Publish DMARC ở chế độ monitor

Đừng bật p=reject ngay ngày đầu. Bắt đầu với p=none: chính sách này không thay đổi cách xử lý email, nhưng bạn sẽ nhận được aggregate report từ các bên nhận có hỗ trợ.

example.com.         TXT  "v=spf1 include:_spf.google.com ~all"
_dmarc.example.com.  TXT  "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.com; adkim=r; aspf=r"
Enter fullscreen mode Exit fullscreen mode

Vài lưu ý:

  • adkim=r và aspf=r là relaxed alignment, cho phép mail.example.com align với example.com. Hầu hết team nên giữ mức này.
  • Nếu rua trỏ sang domain khác, domain đó phải publish record xác nhận dạng example.com._report._dmarc.other-domain.com, nếu không report có thể không được gửi đến.
  • Nên tạo một mailbox riêng cho report, vì lượng thư có thể khá lớn.

Bước 3: Đọc report thay vì đoán

Report là file XML được nén .gz hoặc .zip, thường gửi mỗi ngày một lần. Với team nhỏ, một script ngắn dùng Python 3.10+ là đủ để biết IP nào đang gửi và có pass hay không:

import gzip
import sys
import zipfile
import xml.etree.ElementTree as ET
from collections import Counter
from pathlib import Path


def load_xml(path: Path) -> bytes:
    if path.suffix == ".gz":
        with gzip.open(path) as f:
            return f.read()
    if path.suffix == ".zip":
        with zipfile.ZipFile(path) as z:
            return z.read(z.namelist()[0])
    return path.read_bytes()


stats = Counter()
for arg in sys.argv[1:]:
    root = ET.fromstring(load_xml(Path(arg)))
    for record in root.findall("record"):
        row = record.find("row")
        key = (
            row.findtext("source_ip"),
            row.findtext("policy_evaluated/dkim"),
            row.findtext("policy_evaluated/spf"),
        )
        stats[key] += int(row.findtext("count"))

for (ip, dkim, spf), count in stats.most_common():
    status = "OK  " if "pass" in (dkim, spf) else "FAIL"
    print(f"{status} {count:>7} {ip:<40} dkim={dkim} spf={spf}")
Enter fullscreen mode Exit fullscreen mode
python3 parse_dmarc.py reports/*.xml.gz reports/*.zip | sort | less
Enter fullscreen mode Exit fullscreen mode

Bất kỳ ai cũng có thể gửi file vào mailbox rua, nên hãy coi đây là input không tin cậy. Script trên phù hợp để chạy tay với report đã lọc. Nếu đưa vào pipeline tự động, hãy dùng defusedxml và giới hạn dung lượng file sau giải nén.

Cách đọc kết quả:

  • FAIL từ IP của service bạn đang dùng: thiếu cấu hình. Bật DKIM với domain của bạn hoặc đặt custom MAIL FROM.
  • FAIL từ IP lạ, số lượng nhỏ: thường là forwarding hoặc spoofing, chính là thứ DMARC sinh ra để chặn.
  • FAIL từ IP nội bộ: rất có thể là con cron job bị quên ở trên.

Bước 4: Siết policy theo từng nấc

Khi mọi nguồn hợp lệ đều pass, tăng dần policy và theo dõi report sau mỗi nấc. Thời gian dưới đây là mức mình thấy hợp lý với team nhỏ, không phải quy định cứng:

flowchart LR
    A["p=none<br/>2-4 tuần"] --> B{Nguồn hợp lệ đã pass hết?}
    B -->|Chưa| C[Sửa SPF, DKIM]
    C --> A
    B -->|Rồi| D["p=quarantine<br/>2-4 tuần"]
    D --> E{Có email hợp lệ bị ảnh hưởng?}
    E -->|Có| C
    E -->|Không| F["p=reject"]

Ngoài ra, nên làm thêm hai việc:

  • Domain không gửi mail: khóa luôn bằng v=spf1 -all và v=DMARC1; p=reject, tránh bị dùng để giả mạo.
  • Subdomain: nếu cần chính sách riêng, dùng tag sp=. Có thể tách transactional sang mail.example.com để reputation không dính với email marketing.

Khi email bị từ chối, hãy đọc kỹ bounce message. Với Outlook, lỗi liên quan đến xác thực thường có dạng 550 5.7.515 Access denied, sending domain does not meet the required authentication level. App cũng nên xử lý bounce qua webhook của provider, thay vì coi SMTP 250 OK là email đã đến nơi, vì đó chỉ có nghĩa là server đầu tiên đã nhận thư. Với relay hoặc API của provider, việc từ chối thật sự có thể xảy ra ở chặng sau.

Kết luận

DMARC không khó, nhưng cần làm theo thứ tự để không tự chặn email của mình. Checklist cho tuần này:

  1. Chạy các lệnh dig ở trên cho mọi domain của công ty, kể cả domain không gửi mail.
  2. Kiểm kê các nguồn gửi, đảm bảo mỗi domain chỉ có một record SPF và không quá 10 lookup.
  3. Bật DKIM với domain của bạn trên từng service, dùng key 2048-bit nếu có thể.
  4. Publish p=none kèm rua, rồi đọc report trong 2-4 tuần.
  5. Nâng dần lên quarantine, sau đó là reject.
  6. Theo dõi bounce trong app và đặt alert khi tỷ lệ tăng đột biến, nhất là với các luồng quan trọng như OTP và reset password.

Phần việc ban đầu thường chỉ mất khoảng một buổi chiều cộng vài tuần theo dõi report. Cái giá phải trả nếu bỏ qua là email reset password biến mất mà không có log lỗi nào để bạn lần theo.

Top comments (1)

Collapse
 
onelifevibe profile image
OneLifeVibe •

The alignment example with Amazon SES is the clearest part of this — SPF passing for the SES domain while the visible From stays unaligned is exactly the trap that makes people think their email is authenticated when it isn't. Starting at p=none and actually reading the aggregate reports for a few weeks before tightening to quarantine and then reject is the right order; jumping straight to reject is how teams end up blocking their own password resets. The 10 DNS lookup limit for SPF is another detail worth highlighting — nested includes add up fast, and a permerror there fails in a way that is hard to spot.