diff --git a/content/momentum/4/modules/index.md b/content/momentum/4/modules/index.md
index e9a6bad7..6168409d 100644
--- a/content/momentum/4/modules/index.md
+++ b/content/momentum/4/modules/index.md
@@ -79,6 +79,7 @@ description: "Table of Contents 71 1 Introduction 71 2 ac auth Authentication Ha
| [smtp_cbv](/momentum/4/modules/smtp-cbv) | SMTP Callback Verification |
| [smtp_rcptto_proxy](/momentum/4/modules/smtp-rcptto-proxy) | SMTP Recipient-To Proxy |
| [smtpapi](/momentum/4/modules/smtpapi) | SMTP Engagement Tracking |
+| [spamassassin](/momentum/4/modules/spamassassin) | SpamAssassin Client |
| [spf Modules](/momentum/4/modules/spf) | spf_macros, spf_v1, and senderid (SPF v2) |
| [static-routes](/momentum/4/modules/static-routes) | Static Routes |
| [suppress_spool](/momentum/4/modules/suppress-spool) | Deferred Message Spooling |
diff --git a/content/momentum/4/modules/spamassassin.md b/content/momentum/4/modules/spamassassin.md
new file mode 100644
index 00000000..df55a32d
--- /dev/null
+++ b/content/momentum/4/modules/spamassassin.md
@@ -0,0 +1,255 @@
+---
+lastUpdated: "05/13/2026"
+title: "spamassassin – SpamAssassin Client"
+description: "The spamassassin module is a SpamAssassin client introduced in Momentum 5.0. It connects to an external spamd daemon using the SPAMC protocol, scans a message, and stores the verdict, score, threshold, and symbol list as message context variables that policy and hooks can consume."
+---
+
+The spamassassin module is a SpamAssassin client introduced in Momentum 5.0.
+It connects to an external spamd daemon using the SPAMC protocol, scans a
+message, and stores the verdict, score, threshold, and symbol list as
+message context variables that policy and hooks can consume.
+
+> **NOTE:** This module replaces the legacy, Sieve-based `spamc` module,
+> which was never a supported product feature. New deployments should use
+> `spamassassin`.
+
+> **NOTE:** The spamassassin module does not bundle the SpamAssassin engine.
+> You must install and operate `spamd` separately by following the upstream
+> [Apache SpamAssassin documentation](https://spamassassin.apache.org/),
+> then point this module at it via the `daemon` configuration option.
+
+### Configuration
+
+The spamassassin module is a singleton in the global scope and is declared
+without an instance name. The following is a minimal configuration:
+
+
+
+```
+spamassassin {
+ daemon = "127.0.0.1:783"
+ timeout = 30
+ max_size = 51200
+}
+```
+
+The module reads its configuration during initialization and resolves
+`daemon` into a sockaddr at that time; changes to these options require
+a configuration reload to take effect.
+
+The following configuration options are available:
+
+
+
+- daemon
+
+-
+
+The `host:port` address of the spamd daemon to connect to. Either an IPv4
+or IPv6 literal address (or a hostname that resolves to one) may be used.
+Defaults to `"localhost:783"`.
+
+
+
+- timeout
+
+-
+
+Per-I/O timeout, in seconds, applied to each `poll` while writing the
+request to spamd and while reading the response. Defaults to `30`.
+
+
+
+- max_size
+
+-
+
+The maximum number of body bytes sent to spamd for any one message.
+Messages whose body exceeds this size are truncated at this boundary
+before being sent; the remainder is not scanned. Defaults to `51200`
+(50 KiB).
+
+
+
+
+
+### Protocol
+
+The module uses the SPAMC `SYMBOLS` request (`SYMBOLS SPAMC/1.2`) with
+a `Content-Length` header. It expects a `SPAMD/1.1` response with a zero
+status code, an `EX_OK` indicator, and a `Spam:` line of the form
+
+```
+Spam: true ; 7.12 / 5.00
+```
+
+followed by the matched rule symbols on the next line. Any other response
+shape, a non-zero status, or an I/O failure causes the scan to be marked
+as failed (see `spamc_status` below).
+
+### Message Context Variables
+
+After `spamc_scan` returns, the following variables are available on the
+message in the `ECMESS_CTX_MESS` scope. Policy and hooks read them with
+`msys.core.ec_message_context_get(msg, msys.core.ECMESS_CTX_MESS, "")`
+or the equivalent C API.
+
+
+
+- spamc_status
+
+-
+
+`"ok"` if the scan completed and a valid `SPAMD/1.1` response was parsed;
+`"failed"` otherwise. When the status is `"failed"`, the underlying error
+is logged to the paniclog and the remaining variables below are not set
+for the current scan.
+
+
+
+- spamc_spam
+
+-
+
+`"true"` if spamd classified the message as spam; `"false"` otherwise.
+Set only when `spamc_status` is `"ok"`.
+
+
+
+- spamc_score
+
+-
+
+The SpamAssassin score reported by spamd, formatted as `"%.2f"`. Set only
+when `spamc_status` is `"ok"`.
+
+
+
+- spamc_thresh
+
+-
+
+The required score (spam threshold) reported by spamd, formatted as
+`"%.2f"`. Set only when `spamc_status` is `"ok"`.
+
+
+
+- spamc_symbols
+
+-
+
+The comma-separated list of SpamAssassin rule symbols that matched the
+message, as returned on the line following the `Spam:` line. Set only
+when `spamc_status` is `"ok"`.
+
+
+
+
+
+### Programmatic Use
+
+**NOTE:** Scanning is **not** automatic. The spamassassin module does not
+register any validation hooks of its own; nothing happens until something
+explicitly calls the scan entry point on a message. That call is most
+commonly made from a Lua policy hook that runs after the message body has
+been spooled.
+
+#### Lua
+
+The scan entry point is exposed to Lua under the legacy `msys.spamc`
+namespace (kept for compatibility with policy written against the older
+Sieve-based `spamc` module):
+
+```
+msys.spamc.spamc_scan(msg)
+```
+
+The call is synchronous and blocking with respect to the `spamd`
+exchange; invoke it from a hook that runs in an async/IO context, such as
+`validate_data_spool`, where the message body has been spooled and a
+blocking call is safe. After it returns, read the `spamc_*` variables off
+the message context and act on them.
+
+
+
+The following scriptlet shows the canonical pattern — call the scan,
+branch on `spamc_status`, then on `spamc_spam`, and use `spamc_score`,
+`spamc_thresh`, and `spamc_symbols` to shape the SMTP response or
+downstream policy:
+
+```lua
+require("msys.core");
+require("msys.extended.message");
+require("msys.spamc");
+
+local mod = {};
+
+function mod:validate_data_spool(msg, ac, vctx)
+ msys.spamc.spamc_scan(msg)
+
+ local status = msg:context_get(msys.core.ECMESS_CTX_MESS, "spamc_status")
+ local is_spam = msg:context_get(msys.core.ECMESS_CTX_MESS, "spamc_spam")
+ local score = msg:context_get(msys.core.ECMESS_CTX_MESS, "spamc_score")
+ local thresh = msg:context_get(msys.core.ECMESS_CTX_MESS, "spamc_thresh")
+ local symbols = msg:context_get(msys.core.ECMESS_CTX_MESS, "spamc_symbols")
+
+ if status == "failed" then
+ -- spamd was unreachable or returned an unparseable response;
+ -- the underlying error is already in the paniclog.
+ vctx:set_code(451, "spamc error")
+ elseif is_spam == "true" then
+ vctx:set_code(550,
+ string.format("spam %s %s %s", score, thresh, symbols))
+ else
+ vctx:set_code(250,
+ string.format("ham %s %s %s", score, thresh, symbols))
+ end
+
+ return msys.core.VALIDATE_CONT
+end
+
+msys.registerModule("spamc_scan", mod);
+```
+
+Instead of setting SMTP reply code according to the spam scan status, an
+integration may prefer to tag the message and let a later hook deliver it
+— for example, writing the verdict into a custom header so that
+downstream consumers can act on it:
+
+```lua
+local spam_header = "X-MSYS-Spam-Status"
+if status == "ok" then
+ if is_spam == "true" then
+ msg:header(spam_header, string.format("Yes, score=%s required=%s tests=%s",
+ score, thresh, symbols))
+ else
+ msg:header(spam_header, string.format("No, score=%s required=%s tests=%s",
+ score, thresh, symbols))
+ end
+else
+ msg:header(spam_header, "failed")
+end
+```
+
+#### C
+
+The Lua binding is a thin wrapper over the C entry point declared in
+`modules/generic/ec_spamassassin.h`:
+
+```
+SPAMC_EXPORT(void) spamc_scan(ec_message *m);
+```
+
+The same blocking-I/O contract applies — call it from an async thread
+(for example, via `sp_async_thread_pool_run`) so the main scheduler is
+not held up while `spamd` processes the message.
+
+### Notes
+
+This module is a singleton and does not accept per-instance
+configuration. Loading it more than once is not supported.
+
+For deployment, install SpamAssassin engine separately and ensure `spamd`
+is reachable at the address configured under `daemon` before starting
+Momentum; otherwise every scan will be recorded with
+`spamc_status = "failed"`.
diff --git a/content/momentum/4/modules/summary-all-modules.md b/content/momentum/4/modules/summary-all-modules.md
index 93c0aaaf..ef61a88f 100644
--- a/content/momentum/4/modules/summary-all-modules.md
+++ b/content/momentum/4/modules/summary-all-modules.md
@@ -93,6 +93,7 @@ All modules are listed alphabetically with a brief description. Singleton module
| [“smtp_auth_proxy - SMTP Authentication Proxy”](/momentum/4/modules/smtp-auth-proxy) | 4.2 | Allow edge SMTP servers to forward SMTP AUTH requests to SMTP servers | ✓ | | | |
| [“smtp_cbv – SMTP Callback Verification”](/momentum/4/modules/smtp-cbv) | 4.0 | Perform SMTP Callback Verification | | | ✓ | |
| [“smtp_rcptto_proxy - SMTP Recipient-To Proxy”](/momentum/4/modules/smtp-rcptto-proxy) | 4.2 | Validate a Lua recipient by doing an SMTP call-forward | | | | |
+| [“spamassassin – SpamAssassin Client”](/momentum/4/modules/spamassassin) (*singleton*) | 5.0 | SpamAssassin client; scans messages via an external `spamd` daemon and stores the verdict, score, threshold, and matched symbols in message context variables. Replaces the deprecated `spamc` Sieve-based module. | | | | |
| [spf_macros](/momentum/4/modules/spf) (*singleton*) | 4.0 | Generic macro service for SPF | ✓ | | | |
| [spf_v1](/momentum/4/modules/spf) | 4.0 | Use Sender Policy Framework | | | | [“scriptlet - Lua Policy Scripts”](/momentum/4/modules/scriptlet) |
| [“static-routes - Static Routes”](/momentum/4/modules/static-routes) | 4.2 | Route traffic to a given server by IP address and port | ✓ | | | |