DocumentationSearch docs
  1. 01Quick start
  2. 02The checks
  3. 03The verdict
  4. 04MCP setup
  5. 05Monitoring
  6. 06API
  7. 07CLI
  8. 08Self-hosting

The checks

Copy Markdown

The checks

Each check looks at one thing in the received message, or in the DNS records behind it, and returns a status with the evidence it looked at, such as the header, the record, or the URL. Check ids are stable, so an agent can match on them.

Statuses

Status Meaning
pass Meets the requirement.
warn Works today, but is fragile, unusual, or likely to cause trouble.
fail Does not meet the requirement, for Gmail and Yahoo bulk senders or the relevant RFC.
info Recorded with no judgement, such as a message with no images to check.
skipped Could not be evaluated, with a reason.

A check that cannot run (no network, no envelope, a DNS timeout) is skipped with its reason. It is never passed quietly, and a temporary DNS failure, such as a timeout looking up a DKIM key or the address behind a PTR name, never fails it.

Every failure and warning with a fix also says who fixes it: you, the publisher (a DNS record, a template), or provider, your sending platform (its signing setup, its servers).

Authentication

SPF, reverse DNS, and TLS need the envelope, which is the IP that delivered the message, the envelope sender, and whether the connection used TLS. A message received at a SendScore address has it. A saved file doesn’t, so the CLI skips those checks unless you pass --ip, --mail-from, and --tls.

Id What it checks Fails when
auth.spf SPF for the envelope sender (Return-Path) against the connecting IP The result is fail, softfail, permerror, or none (warns on neutral)
auth.spf_lookups The SPF record’s DNS lookup count, following each include and redirect (a redirect= is ignored when the record has an all mechanism) There is no SPF record, the domain has more than one, an include or redirect names a domain with no SPF record, the record includes itself, more than two lookups find nothing, or it needs more than 10 lookups (warns at 8 or more). When the message passed SPF before reaching the error, this is a warning for mail that reaches that term
auth.dkim Each DKIM signature: result, d= domain, selector, algorithm, and key size. An expiry (x=) is judged at the time the message was received, so a signature that expired after that can no longer be verified and is skipped, not failed There is no signature, or none verifies
auth.dkim_key The smallest RSA key among passing signatures. When one of them is your own domain’s, only those count, and otherwise the fix is your provider’s Under 1024 bits (warns under 2048)
auth.dmarc_record A DMARC record exists for the From domain, and its policy. For a From address on a subdomain, when the record comes from the organizational domain, its sp= policy applies if it sets one There is no record, there is more than one, or it does not parse (warns on p=none with no rua)
auth.dmarc A passing SPF or DKIM result aligns with the From domain Neither aligns. With no single valid DMARC record it is info, "No DMARC policy applies"
auth.alignment Which of SPF and DKIM align, relaxed or strict. It is skipped when SPF aligns but a DKIM signature for the From domain could not be verified Never fails; warns when only SPF aligns, since forwarding breaks SPF
auth.ptr The connecting IP has reverse DNS that resolves back to the same IP. Only the first 10 names are looked up, and a lookup that fails or times out makes it skipped There is no PTR, or it does not match
auth.tls The message arrived over TLS It was delivered without TLS

auth.dmarc is skipped when a result that could have aligned is hidden. That happens when the DMARC record could not be read, when DNS trouble with DKIM or SPF hid a result, when an aligned DKIM signature expired after the message was received, or when there is no envelope and the Return-Path domain aligns with the From domain, so SPF could have passed. It is also skipped when the message has no From domain.

When auth.dmarc or auth.dkim is skipped, nothing shows the message is authenticated, so ready_to_send is false and the verdict is at least warn. When DNS lookups failed, the summary starts with "Authentication could not be checked because DNS lookups failed, so this is not ready to send." and the first fix is to run the check again. Otherwise it starts with "Authentication could not be fully checked, so this is not ready to send." See the verdict.

Unsubscribe

Gmail and Yahoo require one-click unsubscribe from bulk senders.

Id What it checks Fails when
unsub.header List-Unsubscribe is present with an https: URL, read as RFC 2369 sets out, with each link in angle brackets. The last instance of the header is the one read, since it is the one a DKIM signature covers It is missing, there is more than one, its only link is http:, it has no https: link or mailto: address, or it has no link in angle brackets (warns when there is only a mailto:)
unsub.one_click List-Unsubscribe-Post: List-Unsubscribe=One-Click is present with an https: URL (RFC 8058) It is missing, there is more than one, it has the wrong value, or there is no https: URL
unsub.signed Both headers are covered by a passing DKIM signature, as RFC 8058 requires They are not covered. When a signature that could cover them could not be verified, it is skipped and the message is not ready to send
unsub.visible A link in the body that mentions unsubscribe or opt out. When the message has an HTML part, only the HTML counts, since that is what most readers see None is found (a warning, since wording varies)

Structure

Id What it checks Fails when
struct.mime The MIME tree parses cleanly It is malformed (warns when problems such as a malformed Content-Type parameter or more than 64 parts were worked around)
struct.plain_text A text/plain alternative exists and is not empty It is missing (a warning)
struct.html_size The size of the HTML part Over 102 KB, where Gmail clips the message (warns over 90 KB)
struct.headers Message-ID, Date, and a single valid From are present Any is missing or malformed
struct.from_domain The From domain is not a free mailbox domain such as gmail.com or yahoo.com. It is skipped when the From header has no address with a domain It is, so DMARC will fail

Content

Content checks are editorial. A broken link is worth fixing, but it is not a deliverability problem, so these are kept apart from authentication failures and do not block ready_to_send.

Id What it checks Fails when
content.placeholders Unrendered merge tags in the subject, the body, and link, image, and alt attributes, such as {{ }}, {% %}, *|TAG|*, %%tag%%, [FIRST_NAME], and Ghost’s {first_name}. Text inside code, pre, kbd, and samp elements is not searched, and neither ${name} template literals nor Python f-strings such as f"Hi {name}" count Any is found. The title gives the count, and detail names the tags
content.links Each link resolves (HEAD, then GET, with up to 5 redirects and a 10 second timeout). Links whose text or address suggests unsubscribe, subscribe, opt out, preferences, managing a subscription, confirming, verifying, or activating, in English or several other languages, are never fetched, and a redirect into one is not followed. Neither is a link near such a word whose own text says nothing, such as "click here", or a click-tracked link with text like that. So are links to the List-Unsubscribe URL’s host and path, and a URL is left alone when the text of any one of its links says unsubscribe. All the fetches share 30 seconds, and a URL still unfinished then is listed under not_checked, never as broken. So is one that gives no clear answer, such as a timeout, a temporary DNS or network error, or status 401, 403, 429, 502, 503, or 504 Any gives a definite failure, such as 404 or 410, or its host does not exist. When nothing checked was broken but some gave no answer, it is skipped
content.images Each image loads, with the same time limit as links Any fails
content.insecure_links Links and images using http: Any is found (a warning)
content.image_alt Content images have alt text (tracking pixels and hidden images aside) It is missing (a warning)
content.blocklists The hosts of every link and image, and their registrable domains (up to 100 names), against the blocklists you turn on, over DNS. Spamhaus DBL gets both, since it also lists single abused hosts on a shared domain, and SURBL gets registrable domains only. Each list’s test domain is looked up first to prove it is answering A host or domain is listed. It is skipped when blocklists are off, and when any list refuses, times out, or cannot be checked for every name
content.link_text Link text that is a web address (such as example.com/sale or www.example.com) names the same domain the link goes to. File and technology names such as report.pdf or Node.js are not treated as addresses. Links through a known click tracking host, through a tracking subdomain of the sender’s own domain, or through the issue’s own tracking domain (one domain carrying at least three links and more than half of them), are allowed The text names one domain and the link goes to another (a warning)
content.shorteners Links through URL shorteners such as bit.ly or tinyurl.com Any is found (a warning)
content.subject The subject line’s length, capitals, and exclamation marks It is over 70 characters, mostly capitals, or has two or more exclamation marks (a warning), or it is empty
content.preheader What an inbox shows beside the subject, which is the first text in the message It is empty or template boilerplate such as "View in browser" (a warning)
content.text_ratio Whether the issue is made of images with almost no words. The footer and invisible padding characters are not counted as text One image with under 50 characters of text outside the footer, or several images with under 200 (a warning)
content.active Scripts, forms, embedded frames or objects, and attachments Any is found (a warning)

There is no list of "spam words". Mailbox providers filter on reputation, authentication, and engagement, so a word list would flag ordinary copy while claiming to know a filter’s decision. These checks look for things with a factual basis instead.

Blocklist lookups are off unless you turn them on, because the free Spamhaus and SURBL zones are for non-commercial use. Use a Spamhaus DQS key or a SURBL data feed zone for anything commercial, or the free zones for personal use. Spamhaus also refuses the free zones through large public DNS resolvers, and the check then says so instead of passing.

content.links and content.images are skipped with the reason "Link checking is off." unless link checking is on. The app turns it on for test addresses and uploads. The CLI leaves it off unless you pass --links, since fetching every link is slow and touches your tracking links. At most 100 are fetched.

Limits

A check never quietly stops partway through a message.

  • A message whose header block is over 1 MB, or has more than 1,000 header fields, is refused as not an email message.
  • At most 2,000 links and 2,000 images are examined. In a message with more, a check that would have passed is info instead, and its evidence gives the totals and how many were examined. unsub.visible is info too when no unsubscribe link is among the links examined.
  • At most 64 MIME parts are read, and struct.mime warns when there were more.
  • A list in a check’s evidence keeps its first 20 items, with the full count beside it.

Changes

When there is a previous issue from the same newsletter (--previous in the CLI), the verdict’s changes lists what moved between the two:

  • The DKIM signing domains and selectors
  • The Return-Path domain
  • The sending IP and its PTR
  • List-Unsubscribe or one-click unsubscribe added or removed
  • Link hosts added or removed
  • HTML size, when it grows by more than 25%
  • Link and image counts
  • SPF, DKIM, or DMARC results that went from pass to anything else. A result that went from pass to skipped is listed, but it is not counted as worse, since it was not checked.

The CLI’s diff and --previous compare only what the messages themselves carry, with no DNS lookups, so they leave authentication results out. Checking last week’s message against today’s DNS would show today’s answer, not the one it got when it was sent. See the CLI.

Most changes are information. A change that made a check worse names that check in worsened, and those changes together become one warning:

Id What it checks Fails when
changes.regressions Changes since the previous issue that made a check worse Never fails; warns when there is any

What it doesn’t check

SendScore never follows unsubscribe, confirm, verify, or preference links, and never fetches the List-Unsubscribe URL, so a check can’t unsubscribe anyone. Link checking refuses private, loopback, link-local, CGNAT, NAT64, 6to4, and other non-public addresses, so a message can’t make SendScore reach a private network. It does not test inbox placement at any provider, and it does not score how a message reads.