Classify a Message

Send a raw message to get a spam verdict and an Email Classification in one call. This is the endpoint a mail server calls while it delivers mail.

POST/classify-stream

When to Use It

  • Use it for every incoming message you want SpamFoo to judge.
  • To get only an Email Classification with no spam or phishing checks, use Classify Without Spam Checks.

SpamFoo Client records each verdict for the admin console. It also caches verdicts: the same message sent again with the same headers returns the cached verdict until a correction clears the cache.

Request

The body is the raw message (raw .eml content), sent unchanged. See Send the Message in the Request Body.

Header Required Type Description
Content-Type Recommended string message/rfc822
X-SpamFoo-Proxy-Auth In standalone mode, or when not loopback string The proxy secret. See Authenticate Requests.
X-User-ID No string The recipient mailbox, usually a full email address. Turns on the user's own corrections and mailbox-level allow and block rules.
X-Sender-IP No IPv4 or IPv6 IP address of the server that delivered the message to you. Used for SPF and IP reputation. If you can't send it, see relays in Share Your Authentication Results.
X-Sender-Email No email address Envelope sender (SMTP MAIL FROM). Used for authentication checks. Allow and block rules are only checked when this header is present.
X-Recipient-Email No email address Envelope recipient (SMTP RCPT TO). Used for domain-level allow and block rules and for protected identity checks.
X-SpamFoo-Classify No true or false Send false to skip Email Classification. See Skip Email Classification.
X-SpamFoo-Log-ID No string, up to 64 characters Your own ID for the message, written to the classification log. Letters, digits, and . _ : - only; other values are ignored.
X-SpamFoo-Priority No bulk Marks work that isn't live delivery, such as sorting old mail. See Mark Bulk Work.
X-SpamFoo-Deadline-Ms No integer, 1 to 600000 How many milliseconds you'll wait for the verdict. See Set a Deadline.
X-SpamFoo-Recipient No <address>[; user=<id>], repeatable One recipient of the message, to check it once for all its recipients. See Check Several Recipients at Once.
X-SpamFoo-Personalize No per-recipient Adds each recipient's own verdict. Used with X-SpamFoo-Recipient. See Check Several Recipients at Once.
X-SpamFoo-Scan No keep Asks for a scanId that later calls for each mailbox can reuse. See Reuse a Scan for Each Mailbox.
X-SpamFoo-Scan-Id No string A scanId from an earlier answer for the same message. See Reuse a Scan for Each Mailbox.
X-SpamFoo-Boundary No string Names the Authentication-Results headers your mail server added, so SpamFoo Client can trust them. See Share Your Authentication Results.
X-SpamFoo-Auth-Results No string Your mail server's SPF, DKIM, and DMARC results for this message. See Share Your Authentication Results.
X-SpamFoo-Caller No <name>/<version> Names your integration and its version, such as mymailserver/2.1. Use a name that is unique to your integration. It tells SpamFoo Client's logs which integration sent a request, which makes problems easier to trace and helps SpamFoo support. The name is 1 to 32 characters from a-z 0-9 . _ -, and the version is 1 to 32 characters from A-Z a-z 0-9 . _ + -.

SpamFoo Client reads X-SpamFoo-Boundary and X-SpamFoo-Auth-Results only from requests that send the correct proxy secret, even from loopback. X-SpamFoo- headers inside the message itself are never read as instructions, so a sender can't use them to change the result.

Skip Email Classification

Send X-SpamFoo-Classify: false when you only need the spam verdict. Spam and phishing checks still run.

  • The response has the same fields, but classification is an empty string and tabsProbs is an empty array, even for spam and phishing. Use isSpam, bypassReason, and phishingReason instead.
  • Values are case-insensitive. Any value other than one true or false returns 400.
  • If the license includes Email Classification but not spam detection, the request returns 403 with "features": ["spam"].

Mark Bulk Work

Send X-SpamFoo-Priority: bulk when the request isn't part of live mail delivery, such as when you classify the messages already in a mailbox. Bulk requests have their own, smaller limit on how many can be held at once (one less than Max Concurrency, and at least 1), so they can't crowd out live mail.

  • When the bulk limit is full, the request gets 503 with "code": "server_busy", "priority": "bulk", and Retry-After: 2. Wait and send it again.
  • Any other value, or no header, is live delivery.
  • The bulkIntake counts in Check Health show how the bulk limit is used.

Set a Deadline

Send X-SpamFoo-Deadline-Ms when your process stops waiting after a set time, such as an SMTP session timeout. The time counts from when SpamFoo Client reads the header, so time spent sending the message counts against it.

  • When SpamFoo Client expects the wait for a free classification slot to be longer than the time left, it answers right away with 503 and "code": "deadline_exceeded". It also stops waiting when the deadline passes in the queue, and skips work for a caller that has disconnected.
  • The body is {"error": "Classification deadline exceeded", "code": "deadline_exceeded", "deadlineMs": 5000, "queueDepth": 8} with Retry-After: 1. Deliver the message normally.
  • Once classification starts, it runs to the end. The deadline covers only the wait.
  • The value is a whole number of milliseconds from 1 to 600000. Any other value, or the header sent more than once, returns 400.
  • Without the header, nothing changes. A wait that reaches the normal queue timeout first still returns queue_timeout.

Check Several Recipients at Once

A mail server that checks a message during the SMTP session sees all of its recipients at once. Send one X-SpamFoo-Recipient header per recipient, or one header with a comma-separated list, to check the message once for all of them.

  • Each value is a recipient address. Add ; user=<id> when that mailbox's user ID isn't the address, such as X-SpamFoo-Recipient: [email protected]; [email protected]. A value without a valid address returns 400.
  • The models run once. The top-level answer is the verdict without any user's corrections or mailbox rules, so X-User-ID and X-Recipient-Email are ignored on these calls.
  • The answer adds recipients, with one entry per recipient, and recipientsTruncated. Only the first 100 recipients are used. When there are more, recipientsTruncated is true, so don't treat the list as complete.
  • Each entry's userId is the user= value, or else the address, changed to the owning user when your address map lists it as an alias.
  • Each entry's rule says whether an allow or block rule applies to the sender for that recipient: action is allow or block, and scope is mailbox or domain for rules an administrator set, or user for the user's own spam rule. It's null when no rule applies.
  • Send X-SpamFoo-Personalize: per-recipient to add each recipient's own verdict and stampHeaders, with that user's corrections and rules applied. Without X-SpamFoo-Recipient it has no effect. Any other value returns 400.
  • When recipients get different verdicts, how to act is up to you. Per-recipient verdicts aren't recorded for the admin console. Only a call for one mailbox, with X-User-ID, is.
"recipients": [
    {
        "address": "[email protected]",
        "userId": "[email protected]",
        "rule": { "action": "block", "scope": "user" },
        "verdict": { ... },
        "stampHeaders": [ ... ]
    }
],
"recipientsTruncated": false

Reuse a Scan for Each Mailbox

A mail server that checks a message once during the SMTP session, and then again for each mailbox at delivery, can skip most of the second check:

  1. Send the first request without X-User-ID, and add X-SpamFoo-Scan: keep. The answer includes a scanId.
  2. For each mailbox, send the same message with X-User-ID and X-SpamFoo-Scan-Id set to that scanId.
  • When SpamFoo Client still holds the scan, it runs only that user's steps: impersonation checks for their domain, their rules and corrections, and their classification corrections. The answer adds "scanReused": true. Otherwise it checks the message in full, as if the header were absent.
  • X-SpamFoo-Scan is ignored on a request with X-User-ID. Any value other than keep returns 400.
  • The answer can leave out scanId, such as when a rule decided the message or the license doesn't include spam detection. Classify each mailbox normally then.
  • Scans are kept in memory for up to 15 minutes, and a restart drops them. SpamFoo Client can drop the oldest sooner when it holds many.
  • A scan is tied to the message body, not its headers. Headers your mail server adds on the way to the mailbox, and LF line endings in place of CRLF, don't break it. A scanId sent with a different message only causes a full check.
  • A reused result keeps the scores the message got in the first scan, including the phishing detection model's score.
  • The first scan counts toward the admin console statistics and sender reputation. Results built on it are recorded for each mailbox but not counted again.

Share Your Authentication Results

If your mail server already checks SPF, DKIM, and DMARC (the checks that confirm a message came from the domain it claims), it can tell SpamFoo Client its results. SpamFoo Client then treats those results as trusted, and can skip its own DNS lookups when your results are clear. Without these headers, SpamFoo Client doesn't trust any Authentication-Results, ARC-Authentication-Results, Received-SPF or X-SmarterMail- header in the message, because a sender can add one. It runs its own checks instead when the request has X-Sender-IP and X-Sender-Email, and otherwise reports authentication as unknown.

  • X-SpamFoo-Boundary: authserv-id=<id>, such as X-SpamFoo-Boundary: authserv-id=mx.example.com. Your mail server adds Authentication-Results headers to the top of each message, using <id> as its authserv-id (the name at the start of the header value, usually the server's host name). SpamFoo Client trusts the run of Authentication-Results headers with that id at the top of the message. It stops at the first header with another id or at the next Received header. The id is up to 255 characters from A-Z a-z 0-9 . - _ :.
  • X-SpamFoo-Boundary: authserv-id=<id>; relays=<cidr>,..., such as authserv-id=mx.example.com; relays=172.22.1.0/24,fd4d:6169:6c63:6f77::/64. Use this when you can't send X-Sender-IP. List your own mail hosts (up to 16 IPv4 or IPv6 ranges or addresses). When the request has no X-Sender-IP, SpamFoo Client reads the Received headers from the top, skips each hop that came from one of your relays, and uses the IP of the first hop from outside them as the sender IP. Your mail server must add the topmost Received header. If SpamFoo Client can't read a trustworthy IP, the message is classified with no sender IP. This works on /classify-stream only.
  • X-SpamFoo-Boundary: none. Trust no authentication results in the message.
  • X-SpamFoo-Auth-Results. Your results for this message in the Authentication-Results format, such as spf=pass smtp.mailfrom=example.com; dkim=pass header.d=example.com; dmarc=pass header.from=example.com. It must include at least one spf, dkim, or dmarc result, and be at most 2048 characters. These results take the place of the ones in the message. You can send it with or without X-SpamFoo-Boundary.

A value SpamFoo Client can't read is treated as none, and the request is still classified. Send each header at most once. These headers change which authentication results SpamFoo Client trusts, and so senderAuthenticated and the three auth scores in the result. They don't change how the models read the message.

What SpamFoo Client Checks

SpamFoo Client stops at the first check that decides the message. bypassReason in the response tells you which one it was.

  1. Allow and block rules an administrator set for the recipient's mailbox or domain.
  2. Whether the message can be parsed. A message that cannot be parsed is treated as spam.
  3. Phishing checks: links to known phishing sites, impersonation of protected people (when the license includes SpamFoo Anti-Phishing), and sender domains that misspell well-known domains.
  4. The user's own past spam and not-spam corrections, when X-User-ID is sent.
  5. SpamFoo's permanent allow and block entries for the sender's domain or IP address.
  6. The global spam models. For mail that is not spam, the Email Classification model, or the user's own classification corrections.
  7. The Anti-Phishing model, when the license includes SpamFoo Anti-Phishing and an administrator has not turned it off.

Allow and block rules that apply to all mail are checked after the models run. A system-wide allow rule gives way when the user's own corrections for the same sender say the message is spam. See Manage Spam Rules and Senders and Protect Against Phishing.

How the License Changes the Result

License Includes What Runs Result
Spam detection and Email Classification Everything Full result.
Spam detection only Spam and phishing checks classification is spam or phishing for spam, and empty otherwise. tabsProbs is empty.
Email Classification only Email Classification, plus allow and block rules No spam or phishing checks run. isSpam is false and the spam scores are 0, unless an allow or block rule decided the message. confidenceMargin holds the classification confidence.
License expired Nothing 403 with "error": "feature_not_licensed" until a valid license is active.

See Use Classification Only for an integration built on a classification-only license.

Response

A successful request returns 200 with a JSON object. This example is a message that is not spam:

{
    "isSpam": false,
    "classification": "transactions",
    "spamProbability": 0.031,
    "displayConfidence": 0.027,
    "spamLevel": null,
    "textSpamScore": 0.052,
    "metaSpamScore": 0.018,
    "confidenceMargin": 0.469,
    "tabsProbs": [0.08, 0.86, 0.04, 0.02],
    "entropy": 0.138,
    "disagreement": 0.034,
    "shouldEscalate": false,
    "bypassReason": null,
    "spamBypassReason": null,
    "knnThresholdShift": 0,
    "reputationBypass": false,
    "phishingReason": null,
    "phishingScore": null,
    "phishingThreatType": null,
    "phishingEncoderVersion": null,
    "senderAuthenticated": true,
    "spfScore": 1,
    "dkimScore": 1,
    "dmarcScore": 1,
    "attribution": "FromDmarc",
    "dmarcRecord": "RecordPresent",
    "attributedFromDomain": "example.com",
    "attributedEnvelopeDomain": null,
    "envelopeAttribution": "EnvelopeSpf",
    "verdict": {
        "spam": false,
        "spamLevel": null,
        "classification": "transactions",
        "confidence": 0.973,
        "personalized": false,
        "decidedBy": "model",
        "phishing": null
    },
    "stampHeaders": [
        ["X-SpamFoo-Status", "ok"],
        ["X-SpamFoo-Spam", "no"],
        ["X-SpamFoo-Classification", "transactions"],
        ["X-SpamFoo-Confidence", "0.97"],
        ["X-SpamFoo-Personalized", "no"]
    ]
}

verdict and stampHeaders are the simplest fields to act on. See Verdict. Ignore any field you don't know, because new fields can be added.

Field Type Description
isSpam boolean The spam verdict. Base delivery decisions on this field or on verdict.
classification string For mail that is not spam: primary, transactions, updates, or promotions. For spam: spam or phishing. Empty when no Email Classification ran, such as when an allow or block rule decided the message. Compare values without regard to case.
spamProbability number Spam score from 0 to 1. The spam threshold is not 0.5, so use isSpam for the verdict. When a check other than the global models decided, this is that check's own score, such as 1 for a block rule.
displayConfidence number or null spamProbability rescaled so the threshold sits at 0.5: spam is 0.5 or higher and other mail is 0.5 or lower. Use it when you show a score to people. 1 for the known-site, impersonation, and lookalike-domain phishing checks, and null when any other check decided.
spamLevel string or null The global models' own spam grade, such as Spam-High. null when another check decided. Read verdict.spamLevel instead.
textSpamScore number Score from the model that reads the message text.
metaSpamScore number Score from the model that reads headers, authentication, and other metadata.
confidenceMargin number When the global models decide, how far spamProbability is from 0.5. Other checks set their own value, such as 1 for a rule.
tabsProbs array of numbers Email Classification probabilities in the order primary, transactions, updates, promotions. Empty when no Email Classification ran.
entropy number How uncertain the spam score is. Higher means less certain.
disagreement number Difference between textSpamScore and metaSpamScore.
shouldEscalate boolean true when the global models were unsure or disagreed. You can use it to flag a message for review.
bypassReason string or null Which check decided the message when it was not the global models. See Bypass Reasons.
spamBypassReason string or null Set when the user's corrections or a SpamFoo permanent allow entry let the message through. It keeps that reason even when bypassReason shows tabs_knn. null otherwise.
knnThresholdShift number How far the user's past corrections moved the spam threshold without deciding the message on their own. Negative leans toward spam, positive toward not spam, 0 when unused.
reputationBypass boolean true when a SpamFoo permanent allow or block entry decided the message.
phishingReason array of strings or null Why the message is phishing: phishtank_match, identity_impersonation, identity_impersonation_lookalike, or domain_typosquat. When the Anti-Phishing model flags a message, this is ["identity_impersonation_lookalike"].
phishingScore number or null Score from the Anti-Phishing model. null when the model did not run.
phishingThreatType string or null The Anti-Phishing model's threat type: PHISHING, SCAM, UNWANTED, or NONE.
phishingEncoderVersion string or null Name of the Anti-Phishing model that scored the message.
senderAuthenticated boolean true when DMARC passed, or when the domain has no DMARC result and SPF passed for a Return-Path that matches the From domain. false when authentication was not checked. Based only on trusted results and SpamFoo Client's own checks (see Share Your Authentication Results).
spfScore
dkimScore
dmarcScore
number or null Authentication results: 1 pass, 0.5 neutral or no record, 0.3 softfail, 0.2 permerror, 0 fail. null when not checked. Like senderAuthenticated, they come only from trusted results and SpamFoo Client's own checks.
attribution
dmarcRecord
attributedFromDomain
attributedEnvelopeDomain
envelopeAttribution
string or null Which sender domain SpamFoo Client proved, and how. SpamFoo uses these for domain reputation. You don't need them to act on the verdict.
verdict object The verdict in a short, stable form. See Verdict.
stampHeaders array of [name, value] pairs Headers you can add to the delivered message. See Stamped Headers.
scanId string Only with X-SpamFoo-Scan: keep. See Reuse a Scan for Each Mailbox.
scanReused boolean true when the result was built on a kept scan. Left out otherwise.
recipients
recipientsTruncated
array, boolean Only with X-SpamFoo-Recipient. See Check Several Recipients at Once.

Verdict

All verdict values are lowercase, so you can compare them as plain text.

Field Type Description
spam boolean Same as isSpam.
spamLevel string or null low, medium, or high when spam is true, otherwise null. See Spam Levels.
classification string or null primary, transactions, updates, promotions, spam, or phishing. null when no Email Classification ran on mail that is not spam.
confidence number How sure SpamFoo Client is of the verdict it gave, from 0 to 1, for spam and not spam alike. 1 for rules.
personalized boolean true when the user's own rules or corrections took part in the result.
decidedBy string model, user_rule, user_memory (the user's corrections), system_rule (an allow or block rule from the admin console), reputation, phishing, parse_error, or other.
phishing object or null {"score": ..., "threatType": ...} from the phishing detection model, or null when it didn't run.

Stamped Headers

stampHeaders lists headers you can add to the copy of the message you deliver, so mail filters and Sieve scripts can act on the verdict. Add them in the order given. Remove any X-SpamFoo- headers that arrived with the message first. Otherwise a sender could write X-SpamFoo-Spam: no and fool a filter.

Header Values
X-SpamFoo-Status ok. When your integration couldn't classify a message, it can write its own value, such as skipped; reason=timeout.
X-SpamFoo-Spam yes or no.
X-SpamFoo-Spam-Level low, medium, or high. Left out when the message is not spam.
X-SpamFoo-Classification The value of verdict.classification. Left out when it's null.
X-SpamFoo-Confidence verdict.confidence with two decimals, such as 0.97.
X-SpamFoo-Personalized yes or no.

Spam Levels

SpamFoo Client grades each spam verdict, so every mail server sees the same grade for the same message. The grade says how strict a spam cutoff the message still counts as spam at:

  • high: spam even at the strictest cutoff. Spam decided by something other than the global models, such as a rule, the user's corrections, a SpamFoo permanent block entry, or a phishing check, is also high.
  • medium: spam at the normal cutoff, but not the strictest one.
  • low: spam only at the loosest cutoff.

The Spam Sensitivity setting on System > Settings decides which grades count as spam at all. Conservative marks only high as spam, Recommended marks medium and high, and Aggressive marks all three. A message that is not spam has no grade.

SpamFoo Client only grades. Your integration decides what to do with each grade. A recommended starting point:

Result Recommended Starting Action
Not spam Deliver. Add the stamped headers only.
low Deliver to the inbox with a tag. Don't file to Junk on this grade alone.
medium File to Junk.
high File to Junk. If the administrator opts in, your mail server can treat it as its own highest spam level.
phishing classification File to Junk. SpamFoo doesn't yet recommend rejecting phishing during the SMTP session.

Bypass Reasons

Value Meaning
null The global models decided.
system_override An allow or block rule from the admin console decided. classification is empty.
parse_error The message could not be parsed, so it was marked as spam. classification is Spam.
phishtank_match The message links to a known phishing site.
identity_impersonation The sender pretends to be a protected person from outside that person's real domains.
identity_impersonation_lookalike The sender pretends to be a protected person from a domain that misspells or imitates one of that person's real domains.
domain_typosquat The sender's domain is a misspelling of a well-known domain.
knn_memory The user's past corrections for messages from the same sender decided.
knn_memory_cross_sender The user's past corrections of very similar messages from other senders decided.
reputation_allow A SpamFoo permanent allow entry for the sender's IP address or authenticated domain. The spam models were skipped.
reputation_block A SpamFoo permanent block entry for the sender's domain or IP address.
tabs_knn The user's past classification corrections set the Email Classification. Check spamBypassReason for what decided spam or not spam.
phishing_encoder The Anti-Phishing model flagged the message as phishing.

Errors

Status Body Cause
400 {"error": "No email content provided"} The body was empty.
400 {"error": "X-SpamFoo-Classify must be true or false"} The header had another value, or was sent more than once.
400 {"error": "X-SpamFoo-... must be ..."} An X-SpamFoo-Recipient, X-SpamFoo-Personalize, X-SpamFoo-Scan, or X-SpamFoo-Deadline-Ms value isn't valid.
401 {"error": "unauthorized"} The proxy secret was missing or wrong, and the request needed it.
403 {"error": "feature_not_licensed", "features": [...]} The license includes neither spam detection nor Email Classification, X-SpamFoo-Classify: false was sent without spam detection in the license, or the license has expired.
413 {"error": "request_body_too_large", "maxBytes": ...} The message is over the body size limit.
429 {"error": "...", "code": "auth_blocked"} Standalone mode only. Too many wrong secrets came from this address. See Repeated Wrong Secrets.
503 {"error": "server_busy", "code": "server_busy", ...} or {"error": "Classification queue timeout", "code": "queue_timeout", ...} SpamFoo Client is busy. Deliver the message normally. See Handle Busy Responses.
503 {"error": "Classification deadline exceeded", "code": "deadline_exceeded", ...} Only with X-SpamFoo-Deadline-Ms. The deadline couldn't be met. Deliver the message normally. See Set a Deadline.

Example

curl -X POST http://localhost:16253/classify-stream \
    -H "Content-Type: message/rfc822" \
    -H "X-SpamFoo-Proxy-Auth: $SPAMFOO_PROXY_SECRET" \
    -H "X-User-ID: [email protected]" \
    -H "X-Sender-IP: 203.0.113.50" \
    -H "X-Sender-Email: [email protected]" \
    -H "X-Recipient-Email: [email protected]" \
    -H "X-SpamFoo-Log-ID: queue-4F2A91" \
    --data-binary @message.eml
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient { BaseAddress = new Uri("http://localhost:16253") };

await using var eml = File.OpenRead("message.eml");
var content = new StreamContent(eml);
content.Headers.ContentType = new MediaTypeHeaderValue("message/rfc822");

using var request = new HttpRequestMessage(HttpMethod.Post, "/classify-stream") { Content = content };
request.Headers.Add("X-SpamFoo-Proxy-Auth", Environment.GetEnvironmentVariable("SPAMFOO_PROXY_SECRET"));
request.Headers.Add("X-User-ID", "[email protected]");
request.Headers.Add("X-Sender-IP", "203.0.113.50");
request.Headers.Add("X-Sender-Email", "[email protected]");
request.Headers.Add("X-Recipient-Email", "[email protected]");
request.Headers.Add("X-SpamFoo-Log-ID", "queue-4F2A91");

using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();

var result = await response.Content.ReadFromJsonAsync<JsonElement>();
var isSpam = result.GetProperty("isSpam").GetBoolean();
var classification = result.GetProperty("classification").GetString();
Console.WriteLine($"isSpam={isSpam} classification={classification}");
import os

import requests

headers = {
    "Content-Type": "message/rfc822",
    "X-SpamFoo-Proxy-Auth": os.environ.get("SPAMFOO_PROXY_SECRET", ""),
    "X-User-ID": "[email protected]",
    "X-Sender-IP": "203.0.113.50",
    "X-Sender-Email": "[email protected]",
    "X-Recipient-Email": "[email protected]",
    "X-SpamFoo-Log-ID": "queue-4F2A91",
}

with open("message.eml", "rb") as eml:
    response = requests.post(
        "http://localhost:16253/classify-stream",
        data=eml,
        headers=headers,
        timeout=60,
    )

response.raise_for_status()
result = response.json()
print(f"isSpam={result['isSpam']} classification={result['classification']}")