From 0757bbfe2dfc4edacaeb4b35cecb0beb5aa4d038 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 14 May 2026 10:58:58 -0400 Subject: [PATCH 1/4] add support doc for spamassassin module --- content/momentum/4/modules/index.md | 1 + content/momentum/4/modules/summary-all-modules.md | 1 + 2 files changed, 2 insertions(+) diff --git a/content/momentum/4/modules/index.md b/content/momentum/4/modules/index.md index e9a6bad73..6168409de 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/summary-all-modules.md b/content/momentum/4/modules/summary-all-modules.md index 93c0aaaf8..ef61a88f4 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 |  ✓ |   |   |   | From 9d5bb8d1489777399b79273d4b1c34db2fb3e643 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 14 May 2026 11:34:32 -0400 Subject: [PATCH 2/4] docs: add spamassassin module reference page --- content/momentum/4/modules/spamassassin.md | 133 +++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 content/momentum/4/modules/spamassassin.md diff --git a/content/momentum/4/modules/spamassassin.md b/content/momentum/4/modules/spamassassin.md new file mode 100644 index 000000000..4dfe0d34f --- /dev/null +++ b/content/momentum/4/modules/spamassassin.md @@ -0,0 +1,133 @@ +--- +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`. + +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 can 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 + +The module exposes a single C entry point declared in `modules/generic/ec_spamassassin.h`: + +``` +SPAMC_EXPORT(void) spamc_scan(ec_message *m); +``` + +`spamc_scan` opens a TCP connection to the configured daemon, streams up to `max_size` bytes of the message body to it, reads back the response, and populates the message context variables described above. The call performs blocking socket I/O and **must be invoked from an async thread** (for example, from `sp_async_thread_pool_run` or an equivalent scheduling primitive) so that the main scheduler is not blocked while spamd processes the message. + +The spamassassin module does not register a Lua binding of its own. To invoke it from a scriptlet, wire `spamc_scan` into an async callout from your own integration code, then read the `spamc_*` context variables in a subsequent validation hook. + +### Notes + +This module is a singleton and does not accept per-instance configuration. Loading it more than once is not supported. + +For deployment, package SpamAssassin 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"`. From 9b337b4f2a14a6fcfba85a824e8ff6729158872b Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Thu, 14 May 2026 12:05:43 -0400 Subject: [PATCH 3/4] Lua example to use spamc_scan --- content/momentum/4/modules/spamassassin.md | 83 ++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/content/momentum/4/modules/spamassassin.md b/content/momentum/4/modules/spamassassin.md index 4dfe0d34f..0220aaa20 100644 --- a/content/momentum/4/modules/spamassassin.md +++ b/content/momentum/4/modules/spamassassin.md @@ -116,15 +116,90 @@ The comma-separated list of SpamAssassin rule symbols that matched the message, ### Programmatic Use -The module exposes a single C entry point declared in `modules/generic/ec_spamassassin.h`: +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): ``` -SPAMC_EXPORT(void) spamc_scan(ec_message *m); +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 is adapted from `tests/perl-tests/generic/spamc/basic_lua.t` and 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); ``` -`spamc_scan` opens a TCP connection to the configured daemon, streams up to `max_size` bytes of the message body to it, reads back the response, and populates the message context variables described above. The call performs blocking socket I/O and **must be invoked from an async thread** (for example, from `sp_async_thread_pool_run` or an equivalent scheduling primitive) so that the main scheduler is not blocked while spamd processes the message. +To activate the scriptlet, load it through the [scriptlet](/momentum/4/modules/scriptlet) module alongside the spamassassin configuration in [“spamassassin Configuration”](/momentum/4/modules/spamassassin#example.spamassassin.config): + +``` +scriptlet "scriptlet" { + script "spamc_scan" { source = "/etc/ecelerity/scriptlets/spamc_scan.lua" } +} +``` + +Instead of writing the SMTP code, an integration may prefer to tag the message and let a later hook deliver it. The relay-webhook integration uses this pattern (see `modules/cloud/scriptlets/msys/sparkpost/relay_webhook.lua`), 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 spamassassin module does not register a Lua binding of its own. To invoke it from a scriptlet, wire `spamc_scan` into an async callout from your own integration code, then read the `spamc_*` context variables in a subsequent validation hook. +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 From 4df8359842a7d2f536cca609089531a95bfb7795 Mon Sep 17 00:00:00 2001 From: Julie Zhao Date: Fri, 15 May 2026 09:23:51 -0400 Subject: [PATCH 4/4] avoid long lines --- content/momentum/4/modules/spamassassin.md | 113 +++++++++++++++------ 1 file changed, 80 insertions(+), 33 deletions(-) diff --git a/content/momentum/4/modules/spamassassin.md b/content/momentum/4/modules/spamassassin.md index 0220aaa20..df55a32d0 100644 --- a/content/momentum/4/modules/spamassassin.md +++ b/content/momentum/4/modules/spamassassin.md @@ -4,15 +4,24 @@ 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. +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:** This module replaces the legacy, Sieve-based `spamc` module, +> which was never a supported product feature. New deployments should use +> `spamassassin`. -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. +> **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: +The spamassassin module is a singleton in the global scope and is declared +without an instance name. The following is a minimal configuration: @@ -24,7 +33,9 @@ spamassassin { } ``` -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 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: @@ -34,7 +45,9 @@ The following configuration options are available:
-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"`. +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"`.
@@ -42,7 +55,8 @@ The `host:port` address of the spamd daemon to connect to. Either an IPv4 or IPv
-Per-I/O timeout, in seconds, applied to each `poll` while writing the request to spamd and while reading the response. Defaults to `30`. +Per-I/O timeout, in seconds, applied to each `poll` while writing the +request to spamd and while reading the response. Defaults to `30`.
@@ -50,7 +64,10 @@ Per-I/O timeout, in seconds, applied to each `poll` while writing the request to
-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). +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).
@@ -58,17 +75,24 @@ The maximum number of body bytes sent to spamd for any one message. Messages who ### 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 +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). +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 can read them with `msys.core.ec_message_context_get(msg, msys.core.ECMESS_CTX_MESS, "")` or the equivalent C API. +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.
@@ -76,7 +100,10 @@ After `spamc_scan` returns, the following variables are available on the message
-`"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. +`"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.
@@ -84,7 +111,8 @@ After `spamc_scan` returns, the following variables are available on the message
-`"true"` if spamd classified the message as spam; `"false"` otherwise. Set only when `spamc_status` is `"ok"`. +`"true"` if spamd classified the message as spam; `"false"` otherwise. +Set only when `spamc_status` is `"ok"`.
@@ -92,7 +120,8 @@ After `spamc_scan` returns, the following variables are available on the message
-The SpamAssassin score reported by spamd, formatted as `"%.2f"`. Set only when `spamc_status` is `"ok"`. +The SpamAssassin score reported by spamd, formatted as `"%.2f"`. Set only +when `spamc_status` is `"ok"`.
@@ -100,7 +129,8 @@ The SpamAssassin score reported by spamd, formatted as `"%.2f"`. Set only when `
-The required score (spam threshold) reported by spamd, formatted as `"%.2f"`. Set only when `spamc_status` is `"ok"`. +The required score (spam threshold) reported by spamd, formatted as +`"%.2f"`. Set only when `spamc_status` is `"ok"`.
@@ -108,7 +138,9 @@ The required score (spam threshold) reported by spamd, formatted as `"%.2f"`. Se
-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"`. +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"`.
@@ -116,21 +148,34 @@ The comma-separated list of SpamAssassin rule symbols that matched the message, ### Programmatic Use -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. +**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): +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 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 is adapted from `tests/perl-tests/generic/spamc/basic_lua.t` and 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: +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"); @@ -166,15 +211,10 @@ end msys.registerModule("spamc_scan", mod); ``` -To activate the scriptlet, load it through the [scriptlet](/momentum/4/modules/scriptlet) module alongside the spamassassin configuration in [“spamassassin Configuration”](/momentum/4/modules/spamassassin#example.spamassassin.config): - -``` -scriptlet "scriptlet" { - script "spamc_scan" { source = "/etc/ecelerity/scriptlets/spamc_scan.lua" } -} -``` - -Instead of writing the SMTP code, an integration may prefer to tag the message and let a later hook deliver it. The relay-webhook integration uses this pattern (see `modules/cloud/scriptlets/msys/sparkpost/relay_webhook.lua`), writing the verdict into a custom header so that downstream consumers can act on it: +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" @@ -193,16 +233,23 @@ end #### C -The Lua binding is a thin wrapper over the C entry point declared in `modules/generic/ec_spamassassin.h`: +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. +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. +This module is a singleton and does not accept per-instance +configuration. Loading it more than once is not supported. -For deployment, package SpamAssassin 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"`. +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"`.