Catch newsletter problems before your subscribers do.

Run SendScore on a saved email, send a test to your own server, or let it watch every issue. It returns a verdict that lists every problem it found and who needs to fix each one.

  • SPF, DKIM, DMARC, and the one-click unsubscribe Gmail requires
  • Leftover merge tags, broken links, and HTML that Gmail would clip
  • What changed since your last issue, and who needs to fix each problem

See all 31 checks

go install github.com/sendscore/sendscore@latest

Open source, with no account and no server. For you, or for your agent.

The gap

An agent can schedule a broadcast. It can’t see what arrived.

Agents already write newsletters and schedule them through the platform’s API. Then they stop, because nothing tells them what reached a mailbox:

  • Did DKIM sign with your domain?
  • Did the unsubscribe headers survive?
  • Did a {{first_name}} go out unrendered?

SendScore is the step between writing and sending. Run the CLI on an issue saved from a real inbox, or run the app and it receives the preview directly, as a subscriber’s mail server would, with no forwarding service to rewrite headers.

Then it runs fixed checks. The same message always gets the same verdict.

Verdict

Every check returns a status, the evidence it looked at, and who fixes it.

The report below is drawn from the verdict JSON underneath it. The summary says where the issue stands, and when something is wrong, the top fixes say what to do first.

Each check shows the headers or records it read. A check that could not run says why, instead of passing quietly.

[RE] Are you joining the photo editing webinar?Report

PASS Ready to send.

Every check that ran passed.

From
DailyPhotoTips <hello@dailyphototips.com>
Subject
[RE] Are you joining the photo editing webinar?
Received
2026-10-05T20:45:36Z
Size
17785 bytes
FAIL
0
WARN
0
PASS
24
INFO
0
SKIPPED
6

Checks

Show the other 30 checks
PASS

The MIME structure parses cleanlystruct.mime · Structure

{
  "has_html": true,
  "has_text": true
}
PASS

Has a plain text versionstruct.plain_text · Structure

{
  "text_bytes": 1373
}
PASS

HTML is 5 KBstruct.html_size · Structure

{
  "clip_bytes": 104448,
  "html_bytes": 4729
}
PASS

Message-ID, Date, and From are presentstruct.headers · Structure

{
  "date": "Mon, 5 Oct 2026 20:45:36 +0000",
  "from": "hello@dailyphototips.com",
  "message_id": "\u003cREDACTED@convertkit-mail4.com\u003e"
}
PASS

Sent from your own domain (dailyphototips.com)struct.from_domain · Structure

{
  "from_domain": "dailyphototips.com"
}
PASS

DKIM signature verifiedauth.dkim · Authentication

{
  "signatures": [
    {
      "a": "rsa-sha256",
      "aligned": true,
      "d": "dailyphototips.com",
      "key_bits": 2048,
      "key_type": "rsa",
      "result": "pass",
      "s": "cka"
    },
    {
      "a": "rsa-sha256",
      "aligned": false,
      "d": "mail.kit.com",
      "key_bits": 2048,
      "key_type": "rsa",
      "result": "pass",
      "s": "esp1"
    }
  ]
}
PASS

DKIM key is 2048 bitsauth.dkim_key · Authentication

{
  "domain": "dailyphototips.com",
  "weakest_rsa_bits": 2048
}
SKIPPED

SPF not checkedauth.spf · Authentication

No envelope: SPF needs the IP address that delivered the message. Pass --ip and --mail-from, or send the message to a SendScore address.

PASS

DMARC policy is quarantineauth.dmarc_record · Authentication

{
  "domain": "dailyphototips.com",
  "policy": "quarantine",
  "policy_tag": "p",
  "record": "v=DMARC1; p=quarantine; pct=100;"
}
PASS

DMARC passesauth.dmarc · Authentication

{
  "dkim_aligned": true,
  "from_domain": "dailyphototips.com",
  "policy": "quarantine",
  "spf_aligned": false
}
PASS

DKIM aligns with the From domainauth.alignment · Authentication

{
  "dkim_aligned": true,
  "dkim_mode": "r",
  "from_domain": "dailyphototips.com",
  "spf_aligned": false,
  "spf_domain": "ckespa.dailyphototips.com",
  "spf_mode": "r"
}
SKIPPED

Reverse DNS not checkedauth.ptr · Authentication

No envelope: the delivering IP address is unknown.

SKIPPED

TLS not checkedauth.tls · Authentication

No envelope: how the message was delivered is unknown.

PASS

List-Unsubscribe has an https linkunsub.header · Unsubscribe

{
  "uris": [
    "mailto:unsub+REDACTED@convertkit-mail.com",
    "https://example.unsubscribe.convertkit-mail4.com/REDACTED"
  ]
}
PASS

Visible unsubscribe linkunsub.visible · Unsubscribe

{
  "link": "https://example.unsubscribe.convertkit-mail4.com/REDACTED"
}
PASS

No unrendered merge tagscontent.placeholders · Content

{
  "found": []
}
PASS

Every link and image uses httpscontent.insecure_links · Content

{
  "images": 2,
  "links": 4
}
PASS

Every image has alt textcontent.image_alt · Content

{
  "images": 2
}
PASS

The subject line reads cleanlycontent.subject · Content

{
  "characters": 47,
  "subject": "[RE] Are you joining the photo editing webinar?"
}
PASS

The inbox preview starts with real textcontent.preheader · Content

{
  "preview": "Join us as we go over some killer post-processing secrets in Lightroom and Photoshop. Just a quick h"
}
PASS

The message carries real textcontent.text_ratio · Content

{
  "images": 2,
  "text_characters": 740
}
PASS

No link shortenerscontent.shorteners · Content

{
  "links": 4
}
PASS

Links go where their text sayscontent.link_text · Content

{
  "links": 4
}
PASS

No scripts, forms, embeds, or attachmentscontent.active · Content

{
  "found": []
}
SKIPPED

Link domains not looked upcontent.blocklists · Content

Blocklist lookups are off. Set a Spamhaus DQS key, or turn on the free public lists for non-commercial use.

SKIPPED

Links not checkedcontent.links · Content

Link checking is off.

SKIPPED

Images not checkedcontent.images · Content

Link checking is off.

Receipt at SendScore does not prove inbox placement at Gmail or any other provider.

verdict.jsonsendscore.verdict/v1
{
  "schema": "sendscore.verdict/v1",
  "message": {
    "received_at": "2026-10-05T20:45:36Z",
    "from": "DailyPhotoTips <hello@dailyphototips.com>",
    "subject": "[RE] Are you joining the photo editing webinar?",
    "message_id": "<REDACTED@convertkit-mail4.com>",
    "size_bytes": 17785
  },
  "verdict": "pass",
  "ready_to_send": true,
  "summary": "Every check that ran passed.",
  "top_fixes": [],
  "checks": [
    {
      "id": "struct.mime",
      "category": "struct",
      "status": "pass",
      "title": "The MIME structure parses cleanly",
      "evidence": {
        "has_html": true,
        "has_text": true
      }
    },
    {
      "id": "struct.plain_text",
      "category": "struct",
      "status": "pass",
      "title": "Has a plain text version",
      "evidence": {
        "text_bytes": 1373
      }
    },
    {
      "id": "struct.html_size",
      "category": "struct",
      "status": "pass",
      "title": "HTML is 5 KB",
      "evidence": {
        "clip_bytes": 104448,
        "html_bytes": 4729
      }
    },
    {
      "id": "struct.headers",
      "category": "struct",
      "status": "pass",
      "title": "Message-ID, Date, and From are present",
      "evidence": {
        "date": "Mon, 5 Oct 2026 20:45:36 +0000",
        "from": "hello@dailyphototips.com",
        "message_id": "<REDACTED@convertkit-mail4.com>"
      }
    },
    {
      "id": "struct.from_domain",
      "category": "struct",
      "status": "pass",
      "title": "Sent from your own domain (dailyphototips.com)",
      "evidence": {
        "from_domain": "dailyphototips.com"
      }
    },
    {
      "id": "auth.dkim",
      "category": "auth",
      "status": "pass",
      "title": "DKIM signature verified",
      "evidence": {
        "signatures": [
          {
            "a": "rsa-sha256",
            "aligned": true,
            "d": "dailyphototips.com",
            "key_bits": 2048,
            "key_type": "rsa",
            "result": "pass",
            "s": "cka"
          },
          {
            "a": "rsa-sha256",
            "aligned": false,
            "d": "mail.kit.com",
            "key_bits": 2048,
            "key_type": "rsa",
            "result": "pass",
            "s": "esp1"
          }
        ]
      },
      "docs": "https://support.google.com/mail/answer/81126"
    },
    {
      "id": "auth.dkim_key",
      "category": "auth",
      "status": "pass",
      "title": "DKIM key is 2048 bits",
      "evidence": {
        "domain": "dailyphototips.com",
        "weakest_rsa_bits": 2048
      }
    },
    {
      "id": "auth.spf",
      "category": "auth",
      "status": "skipped",
      "title": "SPF not checked",
      "reason": "No envelope: SPF needs the IP address that delivered the message. Pass --ip and --mail-from, or send the message to a SendScore address."
    },
    {
      "id": "auth.spf_lookups",
      "category": "auth",
      "status": "pass",
      "title": "SPF needs 3 of 10 DNS lookups",
      "evidence": {
        "domain": "ckespa.dailyphototips.com",
        "limit": 10,
        "lookups": 3,
        "void_lookups": 0
      },
      "docs": "https://www.rfc-editor.org/rfc/rfc7208#section-4.6.4"
    },
    {
      "id": "auth.dmarc_record",
      "category": "auth",
      "status": "pass",
      "title": "DMARC policy is quarantine",
      "evidence": {
        "domain": "dailyphototips.com",
        "policy": "quarantine",
        "policy_tag": "p",
        "record": "v=DMARC1; p=quarantine; pct=100;"
      },
      "docs": "https://support.google.com/mail/answer/81126"
    },
    {
      "id": "auth.dmarc",
      "category": "auth",
      "status": "pass",
      "title": "DMARC passes",
      "evidence": {
        "dkim_aligned": true,
        "from_domain": "dailyphototips.com",
        "policy": "quarantine",
        "spf_aligned": false
      },
      "docs": "https://www.rfc-editor.org/rfc/rfc7489"
    },
    {
      "id": "auth.alignment",
      "category": "auth",
      "status": "pass",
      "title": "DKIM aligns with the From domain",
      "evidence": {
        "dkim_aligned": true,
        "dkim_mode": "r",
        "from_domain": "dailyphototips.com",
        "spf_aligned": false,
        "spf_domain": "ckespa.dailyphototips.com",
        "spf_mode": "r"
      }
    },
    {
      "id": "auth.ptr",
      "category": "auth",
      "status": "skipped",
      "title": "Reverse DNS not checked",
      "reason": "No envelope: the delivering IP address is unknown."
    },
    {
      "id": "auth.tls",
      "category": "auth",
      "status": "skipped",
      "title": "TLS not checked",
      "reason": "No envelope: how the message was delivered is unknown."
    },
    {
      "id": "unsub.header",
      "category": "unsub",
      "status": "pass",
      "title": "List-Unsubscribe has an https link",
      "evidence": {
        "uris": [
          "mailto:unsub+REDACTED@convertkit-mail.com",
          "https://example.unsubscribe.convertkit-mail4.com/REDACTED"
        ]
      },
      "docs": "https://support.google.com/mail/answer/81126"
    },
    {
      "id": "unsub.one_click",
      "category": "unsub",
      "status": "pass",
      "title": "One-click unsubscribe is set up",
      "evidence": {
        "list_unsubscribe_post": "List-Unsubscribe=One-Click"
      },
      "docs": "https://www.rfc-editor.org/rfc/rfc8058"
    },
    {
      "id": "unsub.signed",
      "category": "unsub",
      "status": "pass",
      "title": "Unsubscribe headers are DKIM signed",
      "evidence": {
        "signed_by": "dailyphototips.com"
      },
      "docs": "https://www.rfc-editor.org/rfc/rfc8058"
    },
    {
      "id": "unsub.visible",
      "category": "unsub",
      "status": "pass",
      "title": "Visible unsubscribe link",
      "evidence": {
        "link": "https://example.unsubscribe.convertkit-mail4.com/REDACTED"
      }
    },
    {
      "id": "content.placeholders",
      "category": "content",
      "status": "pass",
      "title": "No unrendered merge tags",
      "evidence": {
        "found": []
      }
    },
    {
      "id": "content.insecure_links",
      "category": "content",
      "status": "pass",
      "title": "Every link and image uses https",
      "evidence": {
        "images": 2,
        "links": 4
      }
    },
    {
      "id": "content.image_alt",
      "category": "content",
      "status": "pass",
      "title": "Every image has alt text",
      "evidence": {
        "images": 2
      }
    },
    {
      "id": "content.subject",
      "category": "content",
      "status": "pass",
      "title": "The subject line reads cleanly",
      "evidence": {
        "characters": 47,
        "subject": "[RE] Are you joining the photo editing webinar?"
      }
    },
    {
      "id": "content.preheader",
      "category": "content",
      "status": "pass",
      "title": "The inbox preview starts with real text",
      "evidence": {
        "preview": "Join us as we go over some killer post-processing secrets in Lightroom and Photoshop. Just a quick h"
      }
    },
    {
      "id": "content.text_ratio",
      "category": "content",
      "status": "pass",
      "title": "The message carries real text",
      "evidence": {
        "images": 2,
        "text_characters": 740
      }
    },
    {
      "id": "content.shorteners",
      "category": "content",
      "status": "pass",
      "title": "No link shorteners",
      "evidence": {
        "links": 4
      }
    },
    {
      "id": "content.link_text",
      "category": "content",
      "status": "pass",
      "title": "Links go where their text says",
      "evidence": {
        "links": 4
      }
    },
    {
      "id": "content.active",
      "category": "content",
      "status": "pass",
      "title": "No scripts, forms, embeds, or attachments",
      "evidence": {
        "found": []
      }
    },
    {
      "id": "content.blocklists",
      "category": "content",
      "status": "skipped",
      "title": "Link domains not looked up",
      "reason": "Blocklist lookups are off. Set a Spamhaus DQS key, or turn on the free public lists for non-commercial use."
    },
    {
      "id": "content.links",
      "category": "content",
      "status": "skipped",
      "title": "Links not checked",
      "reason": "Link checking is off."
    },
    {
      "id": "content.images",
      "category": "content",
      "status": "skipped",
      "title": "Images not checked",
      "reason": "Link checking is off."
    }
  ],
  "changes": [],
  "fingerprint": {
    "dkim_domains": [
      "dailyphototips.com",
      "mail.kit.com"
    ],
    "dkim_selectors": [
      "cka._domainkey.dailyphototips.com",
      "esp1._domainkey.mail.kit.com"
    ],
    "return_path_domain": "ckespa.dailyphototips.com",
    "list_unsubscribe": true,
    "one_click": true,
    "link_hosts": [
      "example.click.convertkit-mail4.com",
      "example.unsubscribe.convertkit-mail4.com",
      "preferences.convertkit-mail4.com"
    ],
    "html_bytes": 4729,
    "links": 4,
    "images": 2,
    "auth": {
      "dkim": "pass",
      "dmarc": "pass"
    }
  },
  "explanation": null,
  "note": "Receipt at SendScore does not prove inbox placement at Gmail or any other provider."
}

A real issue of Daily Photo Tips, checked by the real engine (sendscore version e9a003b), with the subscriber’s personal tokens removed. It was checked as a saved file with no envelope, so SPF, reverse DNS, and TLS are skipped. Link checks and blocklists are off by default, so those are skipped too.

For agents

Four MCP tools, in the order an agent calls them.

The tools come from the app, which you run yourself. The agent gets an address from it, sends the preview from Kit, Beehiiv, or whatever platform it already uses, and reads the verdict. Then it fixes what failed, or schedules the send.

Illustration of the agent loop. Get a test address, send the preview from your platform, and wait for the verdict. If ready_to_send is no, fix what failed and send again. If yes, schedule the send. 1get_test_addressa temporary address send the previewfrom your platform 2wait_for_messagereturns the verdict fix what failedyou, or your provider schedule the send no send again ready_to_send yes
Illustration. The dashed box is your platform, not SendScore.
  1. get_test_addressReturns a temporary address, its token, and when it expires. The address accepts mail for 24 hours.
  2. wait_for_messageWaits up to 50 seconds for the preview. Returns the verdict, ready_to_send, the summary, the top fixes, and the report’s id, or not_received so the agent can call again.
  3. get_reportReturns the full verdict JSON: every check, with its evidence and fix.
  4. compare_to_previousTakes two reports from the last 7 days, such as a preview before and after a fix. Returns what changed, and marks any change that made a check worse.
claude mcp add --transport http sendscore https://sendscore.example.com/api/mcp

Use your own app’s address. Then run /mcp to sign in by email and press Allow. In claude.ai or another client, add the same address as a custom connector. Or run the local server with sendscore mcp, SENDSCORE_URL, and an API token. All of them serve the same tools, as MCP setup explains. Without MCP, the same steps are three API requests with an API token.

For operators

Then it keeps checking every issue you send.

Monitoring is part of the app. Subscribe a SendScore address on your own server to each newsletter, like any reader.

  1. 1Your issue goes outto your list, which includes the SendScore address
  2. 2SendScore receives itas any subscriber’s mail server would
  3. 3It is checkedand compared with the issue before it
  4. 4You get an emailif it got worse, or never arrived
Heartbeat
Say when an issue normally arrives and how late is too late. If today’s issue has not arrived by then, you hear about it.
Comparison

Each issue is set against the last one:

  • the DKIM signing domain and selector
  • the Return-Path
  • the sending IP and its reverse DNS
  • the unsubscribe headers
  • link hosts and HTML size
  • SPF, DKIM, or DMARC going from pass to anything else

A change in your provider’s signing setup is easy to miss, because the issue still arrives. The comparison catches it on the first issue it affects.

Checks

What it checks.

Thirty-one checks in five groups. A failure in the first two sets ready_to_send to false.

Authentication

blocks sending

Who sent the issue, and how it was delivered.

auth.spf
SPF passes for the envelope sender
auth.spf_lookups
SPF needs 10 DNS lookups or fewer
auth.dkim
A DKIM signature verifies
auth.dkim_key
The signing key is at least 2048 bits
auth.dmarc_record
The From domain publishes a DMARC record
auth.dmarc
DMARC passes
auth.alignment
DKIM aligns with the From domain
auth.ptr
Reverse DNS matches the sending IP
auth.tls
Delivered over TLS

Unsubscribe

blocks sending

The unsubscribe Gmail and Yahoo require of bulk senders.

unsub.header
List-Unsubscribe has an https link
unsub.one_click
One-click unsubscribe header (RFC 8058)
unsub.signed
Unsubscribe headers are DKIM signed
unsub.visible
A visible unsubscribe link in the body

Structure

Built the way mail clients expect.

struct.mime
The MIME structure parses cleanly
struct.plain_text
Has a plain-text version
struct.html_size
HTML under Gmail’s 102 KB clipping limit
struct.headers
Message-ID, Date, and From are present
struct.from_domain
Sent from your own domain, not a free mailbox

Content

What a reader would see go wrong.

content.placeholders
No leftover merge tags
content.links
Every link loads
content.insecure_links
Every link and image uses https
content.images
Every image loads
content.image_alt
Every image has alt text
content.blocklists
No link to a host or domain on Spamhaus DBL or SURBL
content.link_text
Links go where their text says
content.shorteners
No URL shorteners hiding where a link goes
content.subject
A subject that fits on a phone, without capitals or a run of exclamation marks
content.preheader
Preview text, not "View in browser"
content.text_ratio
Real text, not an issue made only of images
content.active
No scripts, forms, embeds, or attachments

Changes

Given the previous issue to compare against.

changes.regressions
Nothing got worse than the previous issue
  • A broken link is not a deliverability problem, so content failures are reported but never block sending.
  • SendScore never follows unsubscribe, confirm, verify, or preference links, and never fetches the List-Unsubscribe URL.

Every check, and when it fails.

Limits

Receipt at SendScore is not inbox placement.

What it sees

  • Exactly what your platform delivered to it
  • The headers, DNS records, and links in that message

What it cannot see

  • Whether Gmail, Outlook, or Yahoo put the message in the inbox, a tab, or spam
  • How any provider filters mail

So it never reports a placement percentage, and every verdict carries a note that says so.

The checks are rules, not a model. The same message with the same DNS gets the same verdict, and there is no AI in the engine.

Open source

All of it is open source. Run it yourself.

Engine and CLI
Every check runs in a Go engine, MIT licensed. The CLI reads a saved .eml file, or two to compare, and prints the report as text or JSON. Run it on your machine or in CI, with no account. sendscore/sendscore
The app
The mail receiver, test addresses, monitoring, reports, the API, and the MCP connector, AGPL-3.0. It needs a server that receives mail on port 25, and a domain whose MX record points at it. sendscore/sendscore-app
Hosted
There is no hosted version. If you would use one, say so on this issue.

Start with sendscore check --json issue.eml. More in the CLI docs and self-hosting.

Check the next issue before it goes out.

Get it on GitHub Read the quick start Self-host the app