our own product · webmail

Folio runs on Siftfy to keep multi-domain inboxes clean.

Updated May 12, 2026

Folio is a webmail product built around a single unified inbox spanning many business domains. Every inbound message — from any of those domains — runs through POST /v1/predict before it ever reaches the user's notification path.

This is our own product, not a customer story. Folio and Siftfy are both operated by VectraSEO LLC. Nobody chose Siftfy at arm's length here — we built both, so treat this as an engineering write-up of how they fit together rather than a recommendation from a third party.

The product

Folio collapses many email addresses across many custom domains into one inbox for one human. Each incoming message displays a colour stripe in the margin keyed to the domain it landed on, and replies auto-select the matching From: address based on which domain received the original. Mail is per-domain DKIM-signed.

The signup model is one paying user with their own DNS records pointing at the Folio MTA — see folioinbox.com for product details and pricing.

Why post-store classification

The webmail backend classifies after the MTA hands off to the storage layer, not during SMTP. Two reasons:

  • The classifier is never on the SMTP critical path. A 10ms hop from inside our cluster to api.siftfy.io is cheap; an SMTP-time call to the same place would couple our mail availability to a third-party outside our incident perimeter.
  • Fail-open is cheap. The message is already persisted before the classifier call. If Siftfy times out, the message stays deliverable and lands in Inbox with a verdict="unavailable" metadata tag — we'd rather let one borderline message through than drop real mail during a transient outage.

Three bands over a ceiling, then two thresholds

Siftfy returns a calibrated probability and, in the same response, the max_confidence it applied to this text. The ceiling is read first and decides which rule applies; only then do our own thresholds get a say.

This page used to be wrong about that. It described a backend that routed on two configured thresholds alone, and the service beneath it compared against a terminal threshold set far above the ceiling of 0.84 that Siftfy clamps every unaided score to. That branch ran on every message and could never once fire. The deployed value is not reproduced here because the constant no longer exists: webemail#1026 retired it and shipped the band read below on 2026-09-21.

  • Strictly above the ceiling — confirmed. Acts alone: the message moves to Spam with no second signal required. See the caveat below — this band is in the code for the contract's sake and cannot fire in our deployment.
  • Exactly at the ceiling — censored. The score is saturated, so it ranks nothing: legitimate transactional mail and a lottery scam both arrive at the same number. It moves mail only when a signal we own agrees — a DNSBL listing, a DMARC failure, a credential lure, or an advance-fee pitch. Otherwise it is annotated and stays in Inbox. We neither blend this value nor subtract from it, because it means "at least this", not "this".
  • Below the ceiling — graded, and clean below its own midpoint. Those two are labelled apart on the stored message and routed identically; the split point is Folio's, not Siftfy's, and is not reproduced here for the same reason the thresholds are not. This is the only band where a score actually ranks, and so the only one our two configured thresholds apply to: SPAM_AUTO_THRESHOLD moves the message to Spam and skips push notifications; SPAM_SUSPICIOUS_THRESHOLD keeps it in Inbox with a "suspicious" annotation visible in the message view. Their deployed values live in Folio's own config and are deliberately not reproduced here — copying a threshold into a second document is the defect this section is a record of. Read reading the score for why the only number to trust is the one in the response.

The branch that cannot fire

The confirmed band above is honest about the contract and inert in practice, and saying so is the whole point of this section. calibrate() ends in min(score, max_confidence), so a score Siftfy reached on the model's own word can never exceed the ceiling — it can only equal it. The one route past the ceiling is a reported correction, and Folio reports none.

That is a decision, not an omission. Wiring the feedback loop would put private mail bodies on a second code path to a second endpoint for no measured gain, so the integration deliberately arms no new automation. Swapping a hardcoded constant for > max_confidence and stopping there would have left us with the same dead branch in newer syntax: the reachable rule here is the censored band plus a signal of our own, and that is the one doing the work.

The integration, end-to-end

Three pieces of information persist on every classified message: the raw probability, the verdict bucket, and a spamModel tag of the form siftfy:<likelihood>. The provider tag is the cheapest insurance against a future provider swap — historical data stays interpretable even if we change classifiers.

python
# Simplified from the production webmail backend. The response's own
# max_confidence decides which rule applies; SPAM_AUTO_THRESHOLD and
# SPAM_SUSPICIOUS_THRESHOLD only rank scores that came back under it.
import httpx

async def classify_message(user_id: str, msg_id: str) -> SpamDecision:
    msg = await repository.get_message(user_id, msg_id)
    if msg.get("folder") != "inbox":
        return SpamDecision(verdict="skipped")

    # User-level allow/block overrides the classifier. "Report spam" /
    # "Not spam" feedback writes a sender or domain preference that
    # short-circuits the API call entirely.
    if (pref := await preference_action(user_id, msg)) == "block":
        await repository.update_message_folder(user_id, msg_id, "spam")
        return SpamDecision(verdict="blocked", moved_to_spam=True)
    if pref == "allow":
        return SpamDecision(verdict="allowlisted")

    # Build the classifier input from the headers + body. Subject + From
    # + To + Served-To carry the spam signal almost as well as the body
    # itself for forwarded marketing campaigns.
    text = build_detector_text(msg)[:20_000]

    try:
        async with httpx.AsyncClient(timeout=settings.spam_detector_timeout_seconds) as client:
            resp = await client.post(
                f"{settings.spam_detector_url}/predict",
                headers={"X-API-Key": settings.spam_detector_api_key},
                json={"text": text},
            )
            resp.raise_for_status()
            payload = resp.json()
    except Exception as exc:
        # Fail open: store the message, mark detector unavailable, deliver.
        # Better to let one borderline message through than to drop legitimate
        # mail because of a transient outage.
        await repository.update_message_spam_metadata(
            user_id, msg_id, verdict="unavailable", error=str(exc)[:300]
        )
        return SpamDecision(verdict="unavailable", error=str(exc))

    probability = float(payload["spam_probability"])
    likelihood = payload.get("likelihood", "")

    # The ceiling Siftfy applied to THIS text, not a number of ours. Absent or
    # malformed, we raise rather than guess: a ceiling we did not receive must
    # never be read as a low one.
    ceiling = unit_score(payload.get("max_confidence"))
    if ceiling is None:
        raise ValueError("invalid Siftfy max_confidence")

    if probability > ceiling:
        # Human-confirmed. Acts alone, and cannot fire on the model's opinion:
        # calibrate() ends in min(score, max_confidence), so only a reported
        # correction lifts a score past the ceiling -- and we report none (see
        # "The branch that cannot fire" below). Written for the contract, not
        # reachable in our deployment.
        band = "confirmed"
    elif probability == ceiling:
        # Saturated. Means "at least this", so it ranks nothing and supports no
        # arithmetic -- we neither blend it nor subtract from it.
        band = "censored"
    else:
        # Splits "graded" from "clean" on Folio's own midpoint, for later
        # interpretation only: both are below the ceiling, so both rank and
        # both go through the thresholds below.
        band = below_ceiling_band(probability)

    if band == "confirmed":
        verdict = "spam"
    elif band == "censored":
        # A score at the ceiling moves mail only when a signal we own agrees:
        # DNSBL, an auth failure, a credential lure, or an advance-fee pitch.
        # Without one it is annotated, never filed away silently.
        verdict = "spam" if corroborating_signal(msg) else "suspicious"
    else:
        # Only here -- for both labels -- do our own tuned thresholds apply, and
        # only to a score that ranks. Both live in Folio's config; their
        # deployed values are deliberately not reproduced on this page.
        ranked = blend_local_signals(probability, msg)
        if ranked >= settings.spam_auto_threshold and corroborating_signal(msg):
            verdict = "spam"
        elif ranked >= settings.spam_suspicious_threshold:
            verdict = "suspicious"
        else:
            verdict = "clean"

    await repository.update_message_spam_metadata(
        user_id, msg_id,
        verdict=verdict,
        probability=probability,
        # Keep the band and the model weights beside the number. A stored score
        # without them is not interpretable later: the ceiling value arriving
        # from a saturated read and the same value reached by a graded one mean
        # different things.
        band=band,
        max_confidence=ceiling,
        model_version=payload.get("model_version"),
        # Tag with provider so a future swap stays traceable in the data layer.
        model=f"siftfy:{likelihood}",
    )
    if verdict == "spam":
        await repository.update_message_folder(user_id, msg_id, "spam")
    return SpamDecision(verdict=verdict, probability=probability)

User feedback ("Report spam", "Not spam") writes to a per-user sender/domain allow-block table that overrides Siftfy's verdict for future mail from that sender. The classifier is the default; user preference is the escape hatch.

What we got from it

  • Single round trip. Spam classification is one HTTP POST inside the cluster. No queues, no async callback, no second model service to run.
  • Calibrated thresholding, bounded by the response. The two thresholds map cleanly onto product UX (move-to-Spam vs. flag-in-Inbox) without retraining or a custom decision layer — and reading max_confidence first is what keeps them applied to scores that rank.
  • Provider-agnostic data layer. Tagging with siftfy:<likelihood> means a future swap to a different classifier (or a self-hosted one) doesn't lose the historical context.
  • Build cost: half a day. Including config wiring, Kubernetes secret, threshold tuning, and the test shape that mocks the API response.
Try Siftfy free

10,000 classifications / month free. /v1/predict reference, related patterns: contact forms, comments.