> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cowboy.inc/llms.txt
> Use this file to discover all available pages before exploring further.

# std.mail

> Per-recipient unsubscribe links, a durable stop list and the RFC 8058 headers

An actor that sends email gives every recipient their own unsubscribe link. A recipient has no wallet, so the link is the actor's Gateway route: `https://0x<actor>.<network>.cowboylabs.net/unsubscribe/<token>`. `cowboy_sdk.std.mail` mints the tokens, serves the page and keeps the stop list.

```python theme={null}
from cowboy_sdk import Actor, OWNER, callable_by, public, pure
from cowboy_sdk.routes import Access, get, post
from cowboy_sdk.std import mail


class Digest(Actor):
    @callable_by(OWNER)
    def add(self, payload):
        email = mail.address(payload["email"])
        mail.enroll(self.storage, email, override=payload.get("override", False))
        ...

    def _message(self, email, body):
        url = mail.link(self.storage, "canyon", email)
        return {"to": email, "text": body + "\n\nUnsubscribe: " + url,
                "headers": mail.headers(url)}

    @public
    @get("/unsubscribe/{token}", access=Access.PUBLIC)
    @pure
    def unsubscribe_page(self, request):
        return mail.page(request)

    @public
    @post("/unsubscribe/{token}", access=Access.PUBLIC)
    def unsubscribe_confirm(self, request):
        email, response = mail.confirm(self.storage, request)
        if email:
            # Drop it from the actor's own list. Return True only when every
            # message already queued for it is confirmed cancelled (or none was).
            cancelled = self._remove(email)
            response = mail.stopped(cancelled)
        return response
```

**How it behaves**

* Pass every address as `address()` returns it. The other functions key on the exact string, so `" Ann@Example.com "` and `"ann@example.com"` are different entries.
* `GET` shows a confirmation page and changes nothing. Mail scanners prefetch links, and the Gateway runs a `GET` as a read.
* `POST` (the page's button, or a mail client's RFC 8058 one-click request) spends the token and stops that one address.
* A token is 32 hex characters hashed from the actor, the minting transaction, the address and a counter, so it cannot be derived from the address. Storage is public: anyone who reads it can use a token, and the worst case is an unwanted unsubscribe.
* A stop is durable. `enroll` refuses a stopped address until the owner re-adds it with `override=True`. The stop list keeps only a hash of the address.
* A replayed or unknown token changes nothing and gets the "may still arrive" page, so a replay never claims a cancellation.

**Functions**

| Function | Use |
| - | - |
| `address(value, max_chars=64)` | a lowercased email address, or `ValueError` |
| `network(value)` | the Gateway network label, such as `"canyon"`, or `ValueError` |
| `enroll(storage, email, override=False)` | the address's token, minted on first use |
| `forget(storage, email)` | drop the token when the actor removes the address another way; records no stop |
| `is_stopped(storage, email)` | whether the address unsubscribed |
| `link(storage, network, email)` / `headers(url)` | the recipient's URL, and its `List-Unsubscribe` and `List-Unsubscribe-Post` headers |
| `page(request)` / `confirm(storage, request)` / `stopped(cancelled)` | the route handlers' answers |

`stopped(cancelled)` answers "You are unsubscribed" only when every message already queued for the address is confirmed cancelled; otherwise it says one may still arrive.

Storage keys are under `unsub:`: `t:<token>`, `e:<email>`, `stop:<sha256(email)>` and the counter `n`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.