Start With the Local API

This walkthrough shows how to connect a mail server or application to SpamFoo Client's local API: send a message, read the result, act on it, and check that the service is healthy. For every field and header, see the API Reference.

Before You Start

  • SpamFoo Client is installed and running. See Install SpamFoo Client.
  • curl http://localhost:16253/health/ready on the server running SpamFoo Client returns 200.
  • Your code can read each incoming message as raw bytes, exactly as it was received.

Know the Basics

  • Base URL: http://localhost:16253
  • Request body: the raw message, also called raw .eml content. This is the full RFC 5322 message, headers and body, as it arrived over SMTP. Send it with Content-Type: message/rfc822.
  • Delivery details: information that is not inside the message, such as the connecting IP address, goes in X- request headers.
  • Responses: JSON with camelCase field names.
  • Endpoints: use the -stream endpoints, which take the message in the request body. SpamFoo Client does not read message files from disk on your behalf.

Authenticate Your Requests

SpamFoo Client checks who is calling with a shared secret called the proxy secret, sent in the X-SpamFoo-Proxy-Auth header. Send this secret on every request.

The samples on this page read the secret from the SPAMFOO_PROXY_SECRET environment variable on the calling machine. See Authenticate Requests for which requests need it, where to find it, and what happens when it changes.

SpamFoo Client listens on localhost only by default. To accept requests from another machine, set SPAMFOO_BIND_ADDRESS as described in Configure SpamFoo Client, and send the secret on every request.

Classify a Message

POST/classify-stream

Send the raw message as the body. All headers are optional, but each one you leave out gives SpamFoo Client less to work with.

Header Value
X-Sender-IP IP address of the server that connected to your mail server to deliver the message. Used for authentication checks and IP reputation.
X-Sender-Email Envelope sender, the address from the SMTP MAIL FROM command. Used for authentication checks and to match allow and block rules.
X-User-ID The recipient mailbox, as a full email address. Turns on personalization for that user. See Identify Users.
X-Recipient-Email The recipient address. Used to match domain-level allow and block rules.
X-SpamFoo-Log-ID Your own ID for this message, such as a queue ID. It appears in SpamFoo Client's classification log so you can match log lines to your own. Up to 64 letters, digits, and . _ : -; other values are ignored.
X-SpamFoo-Classify Send false to get only a spam verdict with no Email Classification. Omit it, or send true, to get both.
X-SpamFoo-Proxy-Auth The proxy secret. Required in standalone mode, and for any request that does not come from loopback.

Some optional headers are for mail servers that already check SPF, DKIM, and DMARC, or that classify old mail in bulk. See Classify a Message.

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

using var http = new HttpClient
{
    BaseAddress = new Uri("http://localhost:16253"),
    Timeout = TimeSpan.FromSeconds(60)
};

var result = await ClassifyAsync(http, "message.eml");
Console.WriteLine(result is null
    ? "No verdict. Deliver normally."
    : $"isSpam={result.IsSpam} classification={result.Classification}");

static async Task<ClassifyResult?> ClassifyAsync(HttpClient http, string emlPath)
{
    await using var eml = File.OpenRead(emlPath);
    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-Sender-IP", "203.0.113.50");
    request.Headers.Add("X-Sender-Email", "[email protected]");
    request.Headers.Add("X-User-ID", "[email protected]");
    request.Headers.Add("X-Recipient-Email", "[email protected]");

    try
    {
        using var response = await http.SendAsync(request);
        if (!response.IsSuccessStatusCode)
            return null;
        return await response.Content.ReadFromJsonAsync<ClassifyResult>();
    }
    catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
    {
        return null;
    }
}

record ClassifyResult(
    bool IsSpam,
    string Classification,
    double SpamProbability,
    double? DisplayConfidence,
    bool ShouldEscalate,
    string? BypassReason);
import os
import requests

SPAMFOO_URL = "http://localhost:16253"


def classify(eml_path):
    headers = {
        "Content-Type": "message/rfc822",
        "X-SpamFoo-Proxy-Auth": os.environ.get("SPAMFOO_PROXY_SECRET", ""),
        "X-Sender-IP": "203.0.113.50",
        "X-Sender-Email": "[email protected]",
        "X-User-ID": "[email protected]",
        "X-Recipient-Email": "[email protected]",
    }
    try:
        with open(eml_path, "rb") as eml:
            response = requests.post(f"{SPAMFOO_URL}/classify-stream",
                                     data=eml, headers=headers, timeout=60)
    except requests.RequestException:
        return None
    if not response.ok:
        return None
    return response.json()


result = classify("message.eml")
print("No verdict. Deliver normally." if result is None
      else f"isSpam={result['isSpam']} classification={result['classification']}")

Each sample returns no result when SpamFoo Client is busy, stopped, or returns an error, so the caller can deliver the message normally.

Read the Result

A successful request returns 200 with a JSON body. This example is shortened to the fields most integrations use:

{
    "isSpam": false,
    "classification": "updates",
    "spamProbability": 0.04,
    "displayConfidence": 0.06,
    "shouldEscalate": false,
    "bypassReason": null
}
Field Meaning
isSpam The spam verdict. Base your delivery decision on this field.
classification For mail that is not spam: primary, transactions, updates, or promotions. For spam: spam, or phishing when SpamFoo Client detected phishing. Empty when no Email Classification was made, for example when an allow or block rule decided the message or the request sent X-SpamFoo-Classify: false. Compare values without regard to case.
spamProbability The raw spam score from 0 to 1. The spam threshold is not fixed at 0.5, so do not compare this value to 0.5 to decide spam. Use isSpam.
displayConfidence The spam score rescaled so the spam 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. It is null when a rule or personalization decided the message.
shouldEscalate true when the models were unsure or disagreed with each other. You can use it to flag a message for review.
bypassReason What decided the message when it was not the global models, such as system_override (an allow or block rule from the admin console) or knn_memory (the user's own past corrections). null when the global models decided.

The response has more fields, including authentication scores and phishing details. See Classify a Message for the full list.

Act on the Result

  • Spam: when isSpam is true, move the message to the junk folder, or handle it the way your server handles spam. You may want to treat phishing more strictly than other spam.
  • Not spam: when isSpam is false, deliver the message. If your mail client shows classification tabs or folders, file it by classification.
  • No classification: when classification is empty and the message is not spam, deliver it to the inbox.
  • No result: when the request fails, deliver the message as if SpamFoo Client were not installed.
  • Remember the verdict: store isSpam and classification with the message if you can. You send them back as the original verdict when a user corrects the message. See Send Corrections Back.

This shell example uses jq to read the result and pick a folder.

result=$(curl -s -f -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]" \
    --data-binary @message.eml) || result=""

if [ -z "$result" ]; then
    folder="Inbox"
elif [ "$(echo "$result" | jq -r '.isSpam')" = "true" ]; then
    folder="Junk"
else
    case "$(echo "$result" | jq -r '.classification | ascii_downcase')" in
        transactions) folder="Transactions" ;;
        updates)      folder="Updates" ;;
        promotions)   folder="Promotions" ;;
        *)            folder="Inbox" ;;
    esac
fi
echo "$folder"
static string ChooseFolder(ClassifyResult? result)
{
    if (result is null)
        return "Inbox";

    var classification = result.Classification?.ToLowerInvariant() ?? "";
    if (result.IsSpam)
        return classification == "phishing" ? "Quarantine" : "Junk";

    return classification switch
    {
        "transactions" => "Transactions",
        "updates" => "Updates",
        "promotions" => "Promotions",
        _ => "Inbox"
    };
}
def choose_folder(result):
    if result is None:
        return "Inbox"

    classification = (result.get("classification") or "").lower()
    if result["isSpam"]:
        return "Quarantine" if classification == "phishing" else "Junk"

    return {
        "transactions": "Transactions",
        "updates": "Updates",
        "promotions": "Promotions",
    }.get(classification, "Inbox")

Handle Errors

Status Meaning What to Do
400 The body was empty, or X-SpamFoo-Classify was not true or false. Fix the request. Deliver the message normally.
401 The proxy secret was missing or wrong, and the request needed it. Send the correct X-SpamFoo-Proxy-Auth header.
403 The license does not include the feature this request needs, or, in standalone mode, the license has expired. The body is {"error": "feature_not_licensed", ...}. Deliver the message without classification. See Know How Licenses Affect Requests.
413 The message is larger than the configured size limit. Deliver the message without classification.
429 Standalone mode only. Too many requests from this address sent a wrong secret, so it is blocked for a while. The Retry-After header says how many seconds are left. Fix the secret. Deliver the message without classification.
503 SpamFoo Client is busy. The code field says why: queue_timeout when the request waited too long in the classification queue, or server_busy when too many requests were already in progress. The response includes a Retry-After header. Deliver the message without classification. Retry only if your mail flow can wait.

Identify Users for Personalization

Personalization means SpamFoo Client adjusts results for each mailbox based on that user's own corrections. It only works when SpamFoo Client knows which user a message belongs to.

  • Send the recipient's full email address in X-User-ID, such as [email protected].
  • Use the same value every time for the same mailbox, in both classification requests and corrections. A different spelling counts as a different user.
  • For a message with several local recipients, classify it once per recipient, each with its own X-User-ID, if each recipient gets their own copy.
  • Without X-User-ID, SpamFoo Client applies no personalization to that message.
  • The user ID also labels the message in the admin console, so administrators can find a user's mail.

To learn how corrections change future results, see How Personalization Works.

Check Health

GET/health/ready

Use this endpoint to decide whether to send mail to SpamFoo Client. It needs no secret. It returns 200 when SpamFoo Client can classify mail, and 503 with a list of reasons when it can't, such as while its models download or while it shuts down.

curl http://localhost:16253/health/ready
GET/health

Use this endpoint to watch the queues. It needs no secret and always returns 200 while the service is up. Check the status field: healthy, or shutting_down while the service is stopping.

curl http://localhost:16253/health
{
    "status": "healthy",
    "shuttingDown": false,
    "activeRequests": 1,
    "modelReloading": false,
    "spoolQueue": {
        "active": 1,
        "waiting": 0,
        "maxConcurrency": 2,
        "totalProcessed": 5120,
        "totalTimedOut": 0
    }
}

This example is shortened. A rising waiting count or totalTimedOut means messages are waiting for classification. See Check Health for every field.

Next Steps