# mailing-list-helper — Complete Documentation

> **Status:** Production (as of 2026-09-14).
> **Version:** 1.0.0 (hardened).
> **Purpose:** Inject a per-recipient, HMAC-signed HTTPS unsubscribe URL into every subscriber's copy of a CGP LIST distribution, so any list mail carries a one-click unsub link that a browser can honor without opening the recipient's mail client.

---

## 1. Problem this solves

CommuniGate Pro's LIST module has a built-in `List-Unsubscribe: <mailto:list-off@dom>` header. That header only works in mail clients that can execute a `mailto:` handoff — which fails silently on browsers, tablet mail apps, and any machine where no OS mail handler is registered (a Chrome-only laptop, for example, just opens an empty tab).

We want a link a **browser** can act on. That means a **per-recipient HTTPS URL** — not a shared one, because we need to know *who* is unsubscribing without asking them. The URL must be:

- Bound to `(list, recipient)` under a shared secret so it can't be forged.
- Injected **into each subscriber's own body** (not just the header) so it renders as normal text/HTML content.
- Fired by CGP once per LIST expansion, not once per queue file (all subscribers on a single queue would otherwise get the same URL).

Stock CGP has no per-recipient body-modification hook. This helper synthesizes one by pairing the Rule `Execute [RCPT]` action with a Submitted/ re-injection.

---

## 2. Architecture at a glance

```
sender ──> LIST module ──> queue file with N R records + Precedence:list header
                              │
                              ▼
                       ┌──── ENQUEUER ─────┐
                       │  Server-Wide Rule  │
                       │  "pipe-inject"     │
                       │  matches           │
                       └──┬────────────────┘
                          │ Execute [RCPT] <exe> ──> mailing-list-helper.exe
                          │   stdin: full message
                          │   argv:  -r "sub1" "sub2" "sub3" ...
                          │
                          │ Discard (drops original)
                          │
              ┌───────────┴───────────┐
              │ helper loops N times: │
              │  ┌─────────────────┐  │
              │  │ compute HMAC    │  │
              │  │ build unique URL│  │
              │  │ inject footer   │  │
              │  │ set To: <sub>   │  │
              │  │ add X-Drift-*   │  │
              │  │ write .sub file │  │
              │  └─────────────────┘  │
              └───────────┬───────────┘
                          │
              ┌───────────▼───────────┐
              │ CGP Submitted/ picks  │
              │ each .sub, re-injects │
              │ as normal 1:1 mail    │
              └───────────┬───────────┘
                          │  (rule re-fires but skips because X-Drift-Injected: 1 is set)
                          ▼
                       delivery
```

**Rule outcome per subscriber:** exactly one message delivered, containing a footer with a URL unique to that subscriber. Click → hits `drift-gateway-helper /unsub` → HMAC verified → CGP CLI removes the subscriber from the list. See `drift-gateway-helper/src/handlers/unsubscribe.rs` for the gateway side.

---

## 3. Wiring reference (one-time CGP setup)

### 3.1 Server-Wide Rule
WebAdmin → **Settings → Mail → Rules → Server-Wide Rules → Create**:

| Field | Value |
|---|---|
| Priority | `2` |
| Name | `pipe-inject` (kept for continuity — you may rename, doesn't affect function) |
| Condition 1 | `Header Field` `is` `Precedence: list` |
| Condition 2 | `Header Field` `is not` `X-Drift-Injected: 1` |
| Condition 3 | `Header Field` `is not` `X-Autogenerated: Redirect` |
| Action 1 | `Execute` `[RCPT] C:\CommuniGate Files\Helpers\mailing-list-helper\mailing-list-helper.exe` |
| Action 2 | `Discard` |

**Why the three conditions:**
- Condition 1 — matches LIST-expanded queue files only (LIST adds `Precedence: list`).
- Condition 2 — loop breaker: our resubmitted .sub carries `X-Drift-Injected: 1`, prevents re-matching.
- Condition 3 — belt-and-braces: if someone ever adds a Redirect action elsewhere, its `X-Autogenerated: Redirect` copy won't loop through us.

**Why `Execute` + `Discard`, not `Redirect to`:**
`[RCPT]` is a Rule Execute action prefix — it appends `-r "sub1" "sub2" ...` to the command line. It does **not** exist as a PIPE address prefix; `[RCPT]<name>@pipe` makes CGP look for a file literally named `[RCPT]<name>` in the PIPE app directory and fails with "not allowed child process name". This is the design mistake we corrected on 2026-09-14; see [Rules Execute doc](https://doc.communigatepro.ru/admin/mail/QueueRules.html).

### 3.2 ChildProcs allowlist
`C:\CommuniGate Files\Settings\ChildProcs.data` must contain (prefix match, whitespace considered, CRLF line endings):

```
C:\CommuniGate Files\Helpers\mailing-list-helper\mailing-list-helper.exe
```

Without this line, CGP rejects the Execute action with `Error Code=not allowed child process name`.

### 3.3 Helper install
Deploy to `C:\CommuniGate Files\Helpers\mailing-list-helper\`:

| File | Purpose |
|---|---|
| `mailing-list-helper.exe` | Stamped release binary |
| `mailing-list-helper.cfg` | Runtime config (see §4) |
| `license.key` (optional in beta) | RSA-signed license — required after 2026-12-31Z |
| `mailing-list-helper.log` | Written at runtime, rotates on operator's schedule |

**Linux paths:** `/var/CommuniGate/Helpers/mailing-list-helper/` (helper looks here as fallback).

### 3.4 Gateway helper — must be running
`drift-gateway-helper` on `127.0.0.1:9084` serves the `/unsub` endpoint that the injected URLs point at. CGP proxies it via the WSSP path `/sys/driftgw.wcgp/` — public URL is `https://<host>/sys/driftgw.wcgp/unsub`. The gateway MUST know the same HMAC secret (see §5). Ideally the gateway is registered as a CGP `ExternalAppHelpers` entry so CGP auto-launches it (see [drift-gateway-helper-cgp-managed](../../memory/drift-gateway-helper-cgp-managed.md)).

---

## 4. Configuration

`mailing-list-helper.cfg` next to the binary:

```ini
# Shared HMAC secret. MUST match drift-gateway-helper's [unsub] hmac_secret.
# Rotating invalidates every outstanding URL. Recommend >= 32 chars of random.
hmac_secret = <64-hex-random>

# Public HTTPS endpoint of the /unsub route on drift-gateway-helper.
# The injected URL will be: {gateway_url}?list=X&email=Y&sig=Z
gateway_url = https://mail2.cgatepro.net/sys/driftgw.wcgp/unsub

# CGP install directory (used to locate the Submitted/ folder). Optional.
# cgp_base_dir = C:\CommuniGate Files

# Override the Submitted/ directory path if it's non-standard. Optional.
# submitted_dir =

# Logging
log_level = 3            # 0=silent, 3=info, 4=debug (dumps argv/stdin summary)
# log_dir =              # defaults to exe dir
```

The helper looks for the file at:

1. `<exe_dir>/mailing-list-helper.cfg`
2. `C:\CommuniGate Files\Helpers\mailing-list-helper\mailing-list-helper.cfg`
3. `/var/CommuniGate/Helpers/mailing-list-helper/mailing-list-helper.cfg`

First existing wins.

---

## 5. The HMAC scheme

**Signing (both helper and gateway must agree):**

```
canonical_list  = list_address.to_lowercase()      # e.g. "testliste@laptop-46vsr8o2"
canonical_email = subscriber.to_lowercase()        # e.g. "postmaster@laptop-46vsr8o2"
material        = canonical_list + "|" + canonical_email
mac             = hmac_sha256(secret, material)
sig             = hex(mac)[0..32]                  # 16 bytes → 32 hex chars
```

**URL template:**

```
{gateway_url}?list={urlencode(canonical_list)}&email={urlencode(canonical_email)}&sig={sig}
```

**Rotation:** changing `hmac_secret` invalidates every outstanding URL. Do this in tandem on the helper and the gateway; overlap is not supported (single-key scheme).

**Recipient normalization:** CGP passes envelope addresses in angle-bracket form (`<sub@dom>`). The helper strips those before computing sig / URL / To: header. The gateway MUST NOT see brackets — its constant-time compare would fail.

**Threat model:**
- Guessing an unsub URL for another subscriber requires forging the HMAC — infeasible without the secret.
- Public HTTPS transport prevents secret leakage over the wire.
- The confirm page is a GET; the actual removal is a POST — so a preloaded/prefetched GET can't accidentally unsubscribe someone.
- Single-key scheme; no per-list keys. Rotate globally if compromise is suspected.

---

## 6. How the helper processes a message

1. **Argv parse.** Collects everything after `-r` (skipping `-r/-f/-p/-R` if they appear). Also reads `-p <return-path>` and `-f <queue-file-path>` (CGP `[RETPATH]`/`[FILE]` prefixes if present). Angle-brackets stripped from each recipient.

2. **Message load.** Prefers `-f <path>` (CGP `[FILE]` prefix) since then stdin is not populated. Otherwise reads full RFC 822 from stdin.

3. **Per-recipient loop.** For each recipient:
   1. **Header parse.** Splits headers at first blank line, walks headers respecting continuations.
   2. **Sanity checks:** must have `Precedence: list`; must NOT already have `X-Drift-Injected: 1` (would indicate a loop).
   3. **List derivation:** takes `List-ID` (preferred) or `List-Post` header; extracts the list address.
   4. **HMAC + URL** computed per §5.
   5. **Body walk.** Top-level MIME type dispatches:
      - `multipart/*` — recursively walks parts, matching the boundary; injects into every `text/plain` and `text/html` leaf.
      - `text/plain` bare — appends the plain-text footer.
      - `text/html` bare — splices footer before the last `</body>` (falls back to appending if no `</body>`).
   6. **CTE handling:** `base64` and `quoted-printable` parts are decoded, footer injected, re-encoded (76-col wrap for base64). `7bit`/`8bit` pass through.
   7. **Header rewrite:** replaces `To:` with the single recipient, appends `X-Drift-Injected: 1`, preserves everything else (`Precedence`, `List-ID`, `List-Unsubscribe`, `From`, `Sender`, etc.) so the recipient sees a normal list mail.
   8. **.sub write.** Atomic `.tmp → rename → .sub` in `Submitted/`. CGP picks up `.sub` files as fresh submissions.

4. **Discard.** The Rule's second action drops the original queue file. Each subscriber gets exactly one mail — the personalized .sub — never a duplicate from the LIST module.

5. **Failsafe:** if injection fails for any reason (missing List-ID, unsupported top-level MIME, malformed body), the helper still writes a **pass-through .sub** with just the loop-breaker header added — the subscriber gets the mail unchanged rather than nothing. **A subscriber must never be denied delivery over an injection error.**

---

## 7. Failure modes and diagnostics

**"not allowed child process name"** — Rule Execute fails at CGP layer. Cause: exe path not whitelisted in `ChildProcs.data`. Fix: add exact absolute path (prefix match — whitespace matters). Restart CGP.

**"no recipients parsed"** — helper log shows argv but no `-r` group. Cause: the Rule action is missing the `[RCPT]` prefix. Rebuild the action as `[RCPT] <full path to exe>`.

**All subscribers get identical URLs** — either the Rule fires at ENQUEUER before LIST expansion (wrong condition), OR the helper isn't parsing `-r` correctly. Check that condition 1 is `Precedence: list` (only present *after* LIST expansion).

**Mail loop, ~40 hops** — the `X-Drift-Injected: 1` header isn't stopping re-entry. Verify condition 2 in the Rule is `Header Field is not X-Drift-Injected: 1` (case matters for the header name in some CGP versions).

**Confirm page 404s / hangs** — the gateway helper isn't reachable at `/sys/driftgw.wcgp/unsub`. Check WSSP wiring, gateway process is running, port 9084 accepting connections, and the form action in the confirm page is `unsub?...` (relative), not `/unsub?...` (root-relative — would bypass WSSP).

**Click confirmed but subscriber still receives mail** — CGP requires two-step unsub confirmation by default. Our gateway bypasses this by using the CLI verb `LIST <listName@dom> UNSUBSCRIBE <addr>` (admin action, no confirmation dance). Verify the gateway's XIMSS session is authenticated as postmaster (has global list-admin).

**[PROTECT] refusing to start** — hardening gate blocked startup. Reasons:
- `tampered` — binary was modified after `stamp.py`. Redeploy a fresh stamped binary.
- `clock-rollback` — system clock moved back > 48h from watermark. Manual fix: delete `.dkw` in the exe dir + `.dkw_wm` in temp; helper re-baselines on next launch.
- `beta-expired` — running unlicensed past 2026-12-31Z. Install a `license.key` that enumerates the `mlist` helper ID.

---

## 8. Build & deploy

### 8.1 Windows (native)

```powershell
cd C:\users\tim\tims-project\drift-helpers
cargo build --release -p mailing-list-helper
python dkim-signer\testtools\stamp.py target\release\mailing-list-helper.exe
# Deploy:
Copy-Item target\release\mailing-list-helper.exe "C:\CommuniGate Files\Helpers\mailing-list-helper\"
```

### 8.2 Linux (via WSL)

```bash
wsl bash -lc "cd /mnt/c/users/tim/tims-project/drift-helpers \
  && CARGO_TARGET_DIR=/tmp/mlist-build cargo build --release -p mailing-list-helper \
  && cp /tmp/mlist-build/release/mailing-list-helper \
       /mnt/c/users/tim/tims-project/drift-helpers/target/release/mailing-list-helper"
python C:/users/tim/tims-project/drift-helpers/dkim-signer/testtools/stamp.py \
       C:/users/tim/tims-project/drift-helpers/target/release/mailing-list-helper
```

### 8.3 Post-build stamping is required.
Every release binary MUST be stamped by `stamp.py` before distribution. An unstamped binary has all-zero hash slot → runtime treats it as a dev build (no integrity enforcement). A stamped binary that is later modified → runtime hash mismatch → refuses to start.

Verify a build is stamped:
```bash
python stamp.py --verify mailing-list-helper.exe
# OK (intact) | FAIL (modified after stamping) | UNSTAMPED
```

---

## 9. Security model summary

Four layers, all provided by the shared `helper-protect` crate:

1. **License gate** — RSA-2048 signed `license.key`. Enumerates which helpers (`mlist` here) are permitted, which domains, expiry, max accounts/domains. Without license → demo mode (functional until beta cutoff 2026-12-31Z).
2. **String obfuscation** — every wrapped string XOR-encrypted at compile time with `SHA256(RSA_PUBLIC_KEY_PEM)`. Swap the public key to accept a rogue license → all wrapped strings decode to garbage.
3. **Self-integrity stamp** — 16-byte magic + 32-byte SHA256 slot written by `stamp.py` post-build. Runtime recomputes; mismatch → refuse.
4. **Anti-rollback** — obfuscated watermark tracks max unix time ever seen; system clock moved backward past 48h → refuse.

Any of the four failing sets `Protection::blocked()` → helper logs the reason and exits 1. CGP's Execute action fails; the LIST module's fallback path delivers each subscriber's copy unchanged (missing the URL, but delivered). **No subscriber loses mail on a hardening failure.**

---

## 10. License mechanics

Each helper carries a short obfuscated identifier and calls `lic.is_helper_allowed("<id>")`. Current IDs across the workspace:

| Helper | ID |
|---|---|
| mmindex-helper | `mmindex` |
| files-index-helper | `files-index` |
| access-rights-helper | `access-rights` |
| whitelabel-filter | `whitelabel` |
| cert-helper | `cert-helper` |
| notification-helper-hardened | `notify` |
| dkim-signer | `dkim` |
| drift-gateway-helper | `gateway` |
| **mailing-list-helper** | **`mlist`** |

**License file grammar:** an RSA-signed JSON payload with a `Helpers` array. Empty array → all helpers allowed (bundle license); non-empty → only the enumerated IDs pass.

**Generation:** `keygenerator/license-generator-v2.py` (Tkinter GUI). Checkboxes per helper. Signs with `private_key.pem` (kept OFF the shipped helper binaries; only the paired public key ships).

---

## 11. Operations

### 11.1 Log location
Windows: `C:\CommuniGate Files\Helpers\mailing-list-helper\mailing-list-helper.log`
Linux:   `/var/CommuniGate/Helpers/mailing-list-helper/mailing-list-helper.log`

### 11.2 Log format & levels
- `INFO` — one line per invocation: `done: N recipients processed (K ok, M fail)`
- `WARN` — per-recipient injection failure (helper still wrote passthrough .sub)
- `ERROR` — write failures, config missing, hardening blocked
- `DEBUG` (log_level 4+) — full argv, parsed recipients, stdin size, per-recipient outcome

### 11.3 Rotating the log
The helper opens the log on each invocation. Rotate by renaming/moving the .log file at any time — next invocation creates a fresh file.

### 11.4 CGP restart is destructive to in-flight mail
The Rule + helper wiring is picked up on next CGP restart. Prefer `Restart-Service "CommuniGate Pro Messaging Server"`; if SCM hangs (has happened here), `Stop-Process` the `CGServer.exe` PID — `CGStarter` respawns it within seconds.

### 11.5 Rotating `hmac_secret`
Change in **both** places atomically:
1. `mailing-list-helper.cfg` (this helper)
2. `[unsub] hmac_secret` in `drift-gateway-helper/config.toml`

Then restart the gateway helper (CGP auto-respawns it on next EXTAPPHELPER supervision cycle). Every outstanding URL is invalidated at the moment of change. There is intentionally no dual-key overlap.

---

## 12. Retired ancestors

Two earlier attempts, both dead code:

- `list-unsub-injector` — first attempt, wired as CGP ExternalFilter. Failed because ExternalFilters run at ENQUEUER once per queue file, so every subscriber got the same body. Retired 2026-09-14.
- `list-pipe-injector` — second attempt, wired as CGP PIPE app. Failed because `[RCPT]` isn't a PIPE address prefix (that's a Rule Execute prefix); CGP looked for a file literally named `[RCPT]list-pipe-injector` in the PIPE app dir and refused to launch it. Renamed to `mailing-list-helper` and switched to Rule Execute 2026-09-14.

---

## 13. Cross-references

- Rule Execute action semantics — `[queue-rules docs](https://doc.communigatepro.ru/admin/mail/QueueRules.html)`
- ExternalAppHelpers registration — `Main.settings` `ExternalAppHelpers` array
- Gateway `/unsub` handler — `drift-gateway-helper/src/handlers/unsubscribe.rs`
- Hardening kit — `drift-helpers/helper-protect/src/lib.rs`
- Post-build stamper — `drift-helpers/dkim-signer/testtools/stamp.py`
- License generator — `keygenerator/license-generator-v2.py`
- Shared HMAC canonicalization — `compute_sig()` in both this crate's `src/main.rs` and `drift-gateway-helper/src/handlers/unsubscribe.rs` (kept identical by convention).
