Metadata-Version: 2.4
Name: exordos_mail
Version: 0.0.9
Summary: SMTP relay PaaS plugin for the Exordos MetaPaaS runtime (exim4)
Author-email: Genesis Corporation <mail@gmelikov.ru>
License: Apache-2.0
Project-URL: homepage, https://github.com/exordos/exordos_mail/
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: oslo.config<10.0.0,>=3.22.2
Requires-Dist: restalchemy<17.0.0,>=16.0.1
Requires-Dist: gcl_iam<2.0.0,>=1.4.0
Requires-Dist: gcl_looper<2.0.0,>=1.2.3
Requires-Dist: gcl_sdk<4.0.0,>=3.6.0
Requires-Dist: exordos_metapaas>=0.0.1
Requires-Dist: passlib>=1.7.0
Provides-Extra: dev
Requires-Dist: tox>=4.0.0; extra == "dev"
Requires-Dist: tox-uv; extra == "dev"
Requires-Dist: pytest>=7.0.0; extra == "dev"
Provides-Extra: test
Requires-Dist: coverage>=4.0; extra == "test"
Requires-Dist: mock<6.0.0,>=3.0.5; extra == "test"
Requires-Dist: pytest<10.0.0,>=8.0.0; extra == "test"
Requires-Dist: pytest-timer<2.0.0,>=1.0.0; extra == "test"
Requires-Dist: exordos<4.0.0,>=2.0.0; extra == "test"
Provides-Extra: ruff
Requires-Dist: ruff; extra == "ruff"
Provides-Extra: mypy
Requires-Dist: mypy; extra == "mypy"

# mailaas: SMTP Relay PaaS Plugin for MetaPaaS

exim4 SMTP submission server packaged as a MetaPaaS plugin,
following the same pattern as `metapaas_s3`.

## Architecture

```
metapaas-cp
└── mail plugin (exordos_paas_mail)
    ├── CP: MailInstance + MailAccount models, REST API, migrations
    ├── infra_builder: CoreInfraBuilder → NodeSet + Config on core
    └── paas_builder: MailInstanceBuilder → MailInstanceNode → DP agent

mailaas-dp-<uuid> (VM)
└── exim4 (SMTP 25/465/587 — STARTTLS + auth)
    ├── /etc/exordos_metapaas/mail.env  ← delivered by CP (MAIL_DOMAIN)
    ├── /etc/exim4/passwd               ← managed by DP agent (lsearch auth)
    └── exordos-universal-agent         ← MailCapabilityDriver
```

## Quick Start

### Build

```bash
make build
```

Produces the DP image, the element manifest and the `exordos_mail` wheel: the
manifest points at that exact wheel as a build artifact, so nothing has to
resolve a version off a pip index.

### Install on running metapaas

```bash
exordos e e install mailaas        # add -v <version> to pin a build
```

PluginReconciler on metapaas-cp installs the `exordos_mail` wheel and
activates the `/v1/types/mail/` route.

### Create Instance

```bash
curl -X POST http://metapaas-cp:8080/v1/types/mail/instances/ \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <token>' \
  -d '{
    "name": "mail-prod",
    "project_id": "4d657461-0000-0000-0000-000000000002",
    "domain": "example.com",
    "cpu": 2,
    "ram": 2048,
    "disk_size": 20,
    "version": "/v1/types/mail/versions/<version-uuid>"
  }'
```

### Create SMTP Account

```bash
# Generate SHA512-crypt hash (compatible with exim4 crypteq)
HASH=$(openssl passwd -6 "mypassword")

curl -X POST http://metapaas-cp:8080/v1/types/mail/instances/<uuid>/accounts/ \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <token>' \
  -d "{
    \"username\": \"alice\",
    \"password_hash\": \"${HASH}\",
    \"project_id\": \"4d657461-0000-0000-0000-000000000002\",
    \"instance\": \"/v1/types/mail/instances/<uuid>\"
  }"
```

Within seconds the DP agent writes the account to `/etc/exim4/passwd` and
reloads exim4. The user can then authenticate via SMTP AUTH (PLAIN/LOGIN over
STARTTLS on port 587 or SMTPS on port 465).

Hashes stored with a Dovecot `{SHA512-CRYPT}` scheme prefix are accepted — the
driver strips the prefix before writing to exim4's passwd file.

### Configure DNS

For mail to be accepted by other servers you must publish a few DNS records for
your domain (referred to below as `$1`, e.g. `example.com`).

- **DKIM** — the key is generated on the node by the configure script. Once the
  instance is `ACTIVE`, read it from the API:

  ```bash
  curl -s http://metapaas-cp:8080/v1/types/mail/instances/<uuid> \
    -H 'Authorization: Bearer <token>' | jq -r '{dkim_selector, dkim_public_key}'
  ```

  Publish it as a `TXT` record at `<dkim_selector>._domainkey.$1`
  (default selector: `platform`), with the `dkim_public_key` value as data.
  The raw record is also on the node at `/etc/exim4/dkim/platform.txt`.
- **SPF** — name: `@` (the domain itself), type: `TXT`, TTL: 3600,
  data: `"v=spf1 ip4:YOUR_SERVER_IP/32 a mx ~all"`
- **DMARC** — name: `_dmarc`, type: `TXT`, TTL: 3600,
  data: `"v=DMARC1; p=none; pct=100; adkim=s; aspf=s"`
- **PTR** — set the reverse record for your server IP to `$1`
- **MX** — ensure an `MX` record exists (e.g. name: `@`, type: `MX`, data: `$1`)

## API Endpoints

| Method | Path | Description |
|--------|------|-------------|
| POST   | `/v1/types/mail/instances/` | Create mail server |
| GET    | `/v1/types/mail/instances/` | List instances |
| GET    | `/v1/types/mail/instances/<uuid>` | Get instance |
| PATCH  | `/v1/types/mail/instances/<uuid>` | Update (cpu/ram/disk_size) |
| DELETE | `/v1/types/mail/instances/<uuid>` | Delete |
| GET    | `/v1/types/mail/versions/` | List DP image versions |
| POST   | `/v1/types/mail/instances/<uuid>/accounts/` | Create account |
| GET    | `/v1/types/mail/instances/<uuid>/accounts/` | List accounts |
| PATCH  | `/v1/types/mail/instances/<uuid>/accounts/<uuid>` | Update (active, password_hash) |
| DELETE | `/v1/types/mail/instances/<uuid>/accounts/<uuid>` | Delete account |

### Field permissions

| Field | Create | Read | Update |
|-------|--------|------|--------|
| `domain` | RW | RO | RO |
| `status` | — | RO | RO |
| `ipsv4` | — | RO | RO |
| `dkim_public_key` | — | RO | RO |
| `dkim_selector` | — | RO | RO |
| `password_hash` | RW | hidden | RW |
| `username` | RW | RO | RO |
| `active` | RW | RW | RW |

## Repository Layout

```
.
├── exordos_paas_mail/
│   ├── constants.py          # MAIL_ENV_FILE, EXIM4_PASSWD_FILE paths
│   ├── models.py             # MailVersion, MailInstance, MailAccount (restalchemy)
│   ├── controllers.py        # REST controllers (gcl_iam policy-based)
│   ├── routes.py             # Route tree (instances/accounts, versions)
│   ├── definition.py         # MailDefinition (PaaSDefinition contract)
│   ├── infra_models.py       # MailInstance + infra (NodeSet + Config)
│   ├── infra_builder.py      # CoreInfraBuilder (creates VMs + delivers mail.env)
│   ├── paas_models.py        # MailInstanceNode (target resource for DP agent)
│   ├── paas_builder.py       # MailInstanceBuilder (maps accounts → DP payload)
│   ├── driver.py             # MailCapabilityDriver: writes /etc/exim4/passwd
│   ├── utils.py              # remove_nested_dm helper
│   ├── migrations/
│   │   ├── 0000-init-mail.py # Creates mail_versions, mail_instances, mail_accounts
│   │   └── 0001-drop-root-password.py
│   └── tests/
│       ├── unit/             # Unit tests (models, driver)
│       └── functional/       # E2E tests (CP API + SMTP auth against a live stand)
├── exordos/
│   ├── exordos.yaml          # Build config (deps + elements + DP image)
│   ├── images/
│   │   ├── dp_install.sh     # Packer: install exim4 + configure script + agent
│   │   ├── dp_bootstrap.sh   # First-boot: persistent disk + start configure service
│   │   └── build_wheel.sh    # Builds the wheel shipped as the pypi_package artifact
│   └── manifests/
│       ├── mailaas.yaml.j2  # Element manifest: type reg + IAM + DP version
│       └── example_mail.yaml.j2  # Example consumer element
├── etc/
│   ├── systemd/
│   │   ├── exordos-metapaas-mail-configure.service  # Configures exim4 from mail.env
│   │   └── exordos-metapaas-mail-agent.service      # Universal agent (MailCapabilityDriver)
│   └── exordos_metapaas/
│       ├── logging.yaml
│       └── metapaas_mail_agent.conf
├── .github/
│   ├── workflows/
│   │   ├── tests.yaml        # Lint (ruff) on every push
│   │   └── build.yml         # Build + push on a self-hosted runner, then e2e
│   └── scripts/
│       └── wait-for-element.sh  # Poll an element until it reports ACTIVE
├── pyproject.toml
├── tox.ini
└── Makefile
```

## Development

### Dataplane bootstrap ordering

The image enables the mail agent without starting it. Both the agent and the
mail configure unit use `Wants` and `After` for `exordos-bootstrap.service`:
they wait for its job to finish, but a failed bootstrap does not disable the
agent or prevent it from reporting configuration errors. A mail-image-only
drop-in also orders the base `exordos-universal-agent.service` after bootstrap:
its `RenderAgentDriver` delivers `mail.env` and invokes the configure hook.
`MailCapabilityDriver` manages accounts and is not the configuration consumer.
The shared base image/repository is unchanged.

Bootstrap prepares `/persist`, migrates and bind-mounts `/var/log`, and then
queues the configure service with `systemctl --no-block enable --now`. A blocking
start here would wait for the bootstrap unit itself and deadlock. The shared
`persist_migrate_complete` helper writes a marker and returns; it does not reboot.

The configure unit retains the `mail.env` condition for delayed delivery and
asserts that **both** `/persist` and `/var/log` are mount points. A missing mount
fails the configuration action instead of silently skipping it or configuring
mail on the root filesystem. Once storage is repaired, the agent can retry the
same configuration action without being re-enabled. There is no automatic disk
repair in this change. The shared bootstrap runner's `__done` file is not a
success gate: that runner currently does not propagate child script failures.

`RequiresMountsFor=/persist /var/log` also makes configure wait for the generated
mount units on subsequent boots. The mount assertions remain necessary on first
boot, before bootstrap has installed the fstab entries. These hard mount
dependencies apply only to configure, never to either recovery agent.

The bootstrap regression tests run the real dataplane script against isolated
command stubs. Unit contracts cover ordering, mount assertions and recovery;
these tests do not replace a first-boot test under systemd on an isolated Realm.

### Local checks

```bash
make test          # Unit tests via tox
make lint          # ruff check
make format        # ruff format
make typecheck     # mypy
make functional    # E2E tests (needs live stand)
```

### Running functional tests manually

Requires a live exordos_core + exordos_metapaas deployment with `mailaas`
installed:

```bash
EXORDOS_ENDPOINT=http://10.20.0.2:80/api/core \
EXORDOS_USERNAME=admin \
EXORDOS_PASSWORD=<pass> \
EXORDOS_MAIL_CP_URL=http://10.20.0.2:80/api/metapaas \
make functional
```

To build that environment from scratch — what the `build` workflow does on a
throwaway runner:

```bash
exordos compute hypervisors init --connection-uri qemu+tcp://10.20.0.1/system
exordos bootstrap -i latest -f -m core --pool-agent-placement local \
  --admin-password <admin-pass> --cidr 10.20.0.0/22
exordos e e install metapaas
exordos e e install mailaas        # add -v <version> to pin a build
```

Wait for both elements to report `ACTIVE`
(`.github/scripts/wait-for-element.sh mailaas`) and the suite has everything it
needs.  To test a build of this working tree rather than the published element,
`make build` it and `exordos push` it to a repository the core can reach first.

## Key differences from metapaas_s3

| Aspect | s3aas | mailaas |
|--------|-------|---------|
| DP software | RustFS | exim4 (SMTP relay) |
| Instance children | Bucket, Policy, User, AccessKey | Account |
| Replicas | 1–16 | Always 1 |
| Config delivered | `rustfs.env` | `mail.env` (domain only) |
| DP auth state | S3 access keys | `/etc/exim4/passwd` (SHA512-crypt) |
| On-change | `systemctl restart rustfs` | `systemctl restart mail-configure` |
| DP agent state file | `s3_meta.json` | `mail_meta.json` |
| uuid5 name | `s3aas` | `mailaas` |

## References

- MetaPaaS design: `../exordos_metapaas/DESIGN.md`
- How to build new PaaS: `../exordos_s3/HOW_TO_BUILD_NEW_PAAS.md`
- Working reference: `../exordos_s3/`
