# The MailMasker API

Version 0.1.1, built 2026-11-04. Everything on https://developer.mailmasker.org in one file: Getting started, then every ability, by area.

## Getting started

Everything in this reference is something a MailMasker account can do from a script or the command line: the same things myaccount, webmail and Drive do for you. Each page has a ready-to-run example in cURL, Perl and homelab-cli.

### The address

Every call goes to `https://api.mailmasker.org`, followed by the path its page shows. The examples use `you@example.com` and `example.com`: put your own address and domain in their place.

### 1. Get a token

[Sign in](https://developer.mailmasker.org/sign-in.md) with your address and password. The answer holds a `token`, good for `expires_in` seconds (a day, at present), and a `refresh_token`, good for 30 days.

The examples read the token from `$TOKEN`, so put it there once:

```
export TOKEN='…'
```

### 2. Send it with every call

As the header `Authorization: Bearer $TOKEN`. Every example already does.

### 3. When it runs out

A call answers 401. Get a fresh token with your refresh token ([Refresh your token](https://developer.mailmasker.org/refresh-token.md)), or sign in again. To end a session for good, [sign out](https://developer.mailmasker.org/sign-out.md).

### Who can use what

Each page says who may use it:

- **Anyone, with no sign-in**: no token needed.
- **Any signed-in account**: your own mail, addresses, files and sessions.
- **Site admins**: running the site.
- **Site admins, and accounts granted a permission**: such as catch-all routing, `domains.catchall`.

The services check every call for themselves, so a page you can read is not always a call you can make: an admin ability answers 403 to everyone else.

### When something goes wrong

- The answer has an `error` field saying what, in plain words, such as `{"error": "not logged in"}`.
- **400**: something is missing or has a wrong value. **401**: no token, or it ran out. **403**: your account may not do that. **404**: no such thing, or it isn't yours. **409**: it clashes with something already there. **429**: too many tries; wait and try again. **502**: a service behind the api isn't answering; try again shortly.
- After 10 failed sign-ins from one internet address within 15 minutes, signing in from that address is refused until the 15 minutes have passed.

### homelab-cli

The command-line client does all of this for you, and keeps your tokens. On Debian or Ubuntu:

```
sudo install -d /etc/apt/keyrings
curl -fsSL https://projects.thedude.vip/apt-homelab/homelab-archive-keyring.asc | sudo tee /etc/apt/keyrings/homelab.asc >/dev/null
echo 'deb [signed-by=/etc/apt/keyrings/homelab.asc] https://projects.thedude.vip/apt-homelab unstable main' | sudo tee /etc/apt/sources.list.d/homelab.list
sudo apt update && sudo apt install homelab-cli
```

Then point it at the api once, and sign in:

```
homelab-cli configure --api-base https://api.mailmasker.org
homelab-cli login you@example.com
```

### For tools and AI assistants

- [The Claude Code skill](https://developer.mailmasker.org/claude-code.md): Claude looks the calls up here as it works.
- [llms.txt](https://developer.mailmasker.org/llms.txt): an index of every page, for AI tools.
- [Everything as one Markdown file](https://developer.mailmasker.org/reference.md), and [as JSON](https://developer.mailmasker.org/reference.json).
- Every page is also plain Markdown: add `.md` to its address, as in [https://developer.mailmasker.org/sign-in.md](https://developer.mailmasker.org/sign-in.md).

## Sign-in and tokens

Sign in with your address and password to get a token, and send it as `Authorization: Bearer <token>` with every other call. When it runs out, trade the refresh token that came with it for a new pair, without your password. Signing out ends that session for good.

### Create an account

`POST https://api.mailmasker.org/api/v1/auth/register`

Make a new account with an invite, an address and a password.

- Who can use it: Anyone, with no sign-in
- Area: Sign-in and tokens
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/create-account

Accounts are by invitation only: `invite_token` must be an unused invite, and creating the account uses it up. The account can sign in straight away, but this call doesn't sign it in: sign in next to get a token.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `email` | body | yes | The new account's address, which is also what it signs in with. | `newuser@example.com` |
| `password` | body | yes | The new account's password. | `$PASSWORD` |
| `invite_token` | body | yes | The token from an invite link. | `$INVITE_TOKEN` |
| `recovery_email` | body | no | An address outside this mail system, where a password-reset link can be sent. | `personal@example.com` |

#### Good to know

- An address that already has an account answers 409, and the invite is not used up.
- Leaving out invite_token answers 400. An invite that is unknown, used or expired answers 403, and so does an address outside the invite's own domain (mailmasker.org).
- After ten failed sign-ins or invite attempts from one network address within 15 minutes, it answers 429; wait 15 minutes.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/auth/register' \
  -H 'Content-Type: application/json' \
  -d "{\"email\":\"newuser@example.com\",\"password\":\"$PASSWORD\",\"invite_token\":\"$INVITE_TOKEN\",\"recovery_email\":\"personal@example.com\"}"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/auth/register',
    json => { email => 'newuser@example.com', password => $ENV{PASSWORD}, invite_token => $ENV{INVITE_TOKEN}, recovery_email => 'personal@example.com' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli register newuser@example.com --invite-token "$INVITE_TOKEN"
```

#### Response

201 Created:

```json
{ "id": 42, "email": "newuser@example.com" }
```

### Sign in

`POST https://api.mailmasker.org/api/v1/auth/login`

Trade your address and password for a token and a refresh token.

- Who can use it: Anyone, with no sign-in
- Area: Sign-in and tokens
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/sign-in

Starts a new session. Send `token` with every other call until it runs out, `expires_in` seconds from now. Keep `refresh_token` as secret as your password: it gets you a new token without one.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `email` | body | yes | Your address. | `you@example.com` |
| `password` | body | yes | Your password. | `$PASSWORD` |

#### Good to know

- A wrong address or password answers 401, and so does an account a site admin has switched off.
- After ten failed attempts from one network address within 15 minutes, it answers 429; wait 15 minutes.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/auth/login' \
  -H 'Content-Type: application/json' \
  -d "{\"email\":\"you@example.com\",\"password\":\"$PASSWORD\"}"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/auth/login',
    json => { email => 'you@example.com', password => $ENV{PASSWORD} })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli login you@example.com
```

#### Response

200 OK:

```json
{
  "success": true,
  "token": "<token>",
  "refresh_token": "<refresh token>",
  "expires_in": 86400,
  "email": "you@example.com"
}
```

### Check your token

`GET https://api.mailmasker.org/api/v1/auth/introspect`

Who a token belongs to, when it runs out, and the account's roles.

- Who can use it: Any signed-in account
- Area: Sign-in and tokens
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/check-token

Answers only while the token still works: it hasn't run out, and its session hasn't been ended. `exp` is when it runs out, in seconds since 1970 (UTC). `jti` is the id of its session, as your session list shows it.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `capability` | query | no | A permission name, such as audit.view. The answer then also says, as `has_capability`, whether the account holds it. Site admins hold every permission. |  |

#### Good to know

- A missing, run-out or signed-out token answers 401.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/auth/introspect' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/auth/introspect',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli whoami
```

#### Response

200 OK:

```json
{
  "email": "you@example.com",
  "exp": 1793850130,
  "roles": ["user"],
  "jti": "7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f"
}
```

### Refresh your token

`POST https://api.mailmasker.org/api/v1/auth/refresh`

Trade your refresh token for a new token and a new refresh token.

- Who can use it: Anyone, with no sign-in
- Area: Sign-in and tokens
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/refresh-token

Keeps a session going without your password. A refresh token works once: keep the new one in place of the old, and expect the token you had to stop working now. If the answer never reached you, sending the old refresh token again within a few minutes hands back the same new pair, marked `"recovered": true`.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `refresh_token` | body | yes | The refresh token from signing in, or from your last refresh. | `$REFRESH_TOKEN` |

#### Good to know

- An unknown, run-out or signed-out refresh token answers 401, and so does one whose account a site admin has switched off; sign in again.
- Changing or resetting your password ends every other session's refresh token as well as its token.
- A refresh token lasts 30 days unless the server is set otherwise, so a session that refreshes at least that often never needs your password again.
- homelab-cli refreshes for you whenever its token runs out, so it has no command for this.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/auth/refresh' \
  -H 'Content-Type: application/json' \
  -d "{\"refresh_token\":\"$REFRESH_TOKEN\"}"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/auth/refresh',
    json => { refresh_token => $ENV{REFRESH_TOKEN} })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "success": true,
  "token": "<token>",
  "refresh_token": "<refresh token>",
  "expires_in": 86400
}
```

### Sign out

`POST https://api.mailmasker.org/api/v1/auth/logout`

End the session a refresh token belongs to.

- Who can use it: Anyone, with no sign-in
- Area: Sign-in and tokens
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/sign-out

The refresh token stops working, and so does that session's token, at once rather than when it would have run out. Your other sessions, on other devices or in other apps, stay signed in; "Sign out your other sessions" ends those.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `refresh_token` | body | yes | The refresh token of the session to end. | `$REFRESH_TOKEN` |

#### Good to know

- The answer is the same for a refresh token that was already signed out, or never existed.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/auth/logout' \
  -H 'Content-Type: application/json' \
  -d "{\"refresh_token\":\"$REFRESH_TOKEN\"}"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/auth/logout',
    json => { refresh_token => $ENV{REFRESH_TOKEN} })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli logout
```

#### Response

200 OK:

```json
{ "success": true }
```

### Change your password

`POST https://api.mailmasker.org/api/v1/auth/password`

Set a new password, given your current one.

- Who can use it: Any signed-in account
- Area: Sign-in and tokens
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/change-password

Works whether or not you have a recovery address. The tokens of your other sessions stop working at once, while the session making this call stays signed in. Mail apps that sign in with your password, such as a phone's, need the new one too.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `current_password` | body | yes | Your password now. | `$PASSWORD` |
| `new_password` | body | yes | The new password, at least 8 characters. | `$NEW_PASSWORD` |

#### Good to know

- A wrong current password answers 403, and a new one shorter than 8 characters answers 400.
- If someone else may be signed in as you, sign out your other sessions first, then change your password.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/auth/password' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d "{\"current_password\":\"$PASSWORD\",\"new_password\":\"$NEW_PASSWORD\"}"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/auth/password',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { current_password => $ENV{PASSWORD}, new_password => $ENV{NEW_PASSWORD} })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true }
```

## Sessions

A session is one place you are signed in: a device, a script, or an app you opened through the sign-in page (webmail, Drive and myaccount each keep their own). Ending a session signs it out at once.

### List your sessions

`GET https://api.mailmasker.org/api/v1/auth/sessions`

Every place you are signed in, the newest first.

- Who can use it: Any signed-in account
- Area: Sessions
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/list-sessions

Each session shows the program it came from (`user_agent`), the network address it signed in from, and when it began (`first_seen_at`), which stays put however often it refreshes. `expires_at` is when its current token runs out, `current` marks the session making this call, and `jti` is the id to end it by.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `user` | query | no | Another account's address, to list its sessions instead. Site admins only; anyone else gets 403. |  |

#### Good to know

- A session whose token has run out isn't listed, though it can still refresh and come back. Signing out your other sessions ends those too.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/auth/sessions' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/auth/sessions',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli sessions list
```

#### Response

200 OK:

```json
[
  {
    "jti": "7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f",
    "user_agent": "Mozilla/5.0 (X11; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0",
    "ip_address": "203.0.113.7",
    "first_seen_at": "2026-11-03 21:40:02.654321-06",
    "expires_at": "2026-11-04 21:40:02.654321-06",
    "current": true
  },
  {
    "jti": "c41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41a",
    "user_agent": "homelab-cli/0.1.0 (Linux 6.8.0-49-generic)",
    "ip_address": "198.51.100.23",
    "first_seen_at": "2026-11-01 09:15:42.246810-06",
    "expires_at": "2026-11-04 08:05:31.135792-06",
    "current": false
  }
]
```

### End a session

`DELETE https://api.mailmasker.org/api/v1/auth/sessions/:jti`

Sign one of your sessions out at once, wherever it is.

- Who can use it: Any signed-in account
- Area: Sessions
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/end-session

Its token stops working straight away, and so does its refresh token, so it can't quietly sign itself back in. Ending the session you are using signs you out here too.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `jti` | path | yes | The session's id, in full, as your session list shows it. | `c41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41a` |
| `user` | query | no | Another account's address, to end one of its sessions. Site admins only; anyone else gets 403. |  |

#### Good to know

- A session that isn't yours, or was already ended, answers 404.
- homelab-cli also takes just the start of the id, as long as only one session matches it.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/auth/sessions/c41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41a' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/auth/sessions/c41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41a',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli sessions revoke c41a9e07b2d8
```

#### Response

200 OK:

```json
{ "ok": true }
```

### Sign out your other sessions

`DELETE https://api.mailmasker.org/api/v1/auth/sessions`

End every session but this one, or every one including it.

- Who can use it: Any signed-in account
- Area: Sessions
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/sign-out-other-sessions

Ends your sessions all at once, along with their refresh tokens, including any whose token has run out but could still refresh. With `except_current=true` the session making this call stays signed in; with `false` it ends too, which signs you out everywhere. `revoked` is how many sessions ended.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `except_current` | query | yes | true to keep the session making this call, false to end it too. There is no default. | `true` |

#### Good to know

- Leaving out except_current, or sending anything but true or false, answers 400.
- It only ever ends your own sessions.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/auth/sessions?except_current=true' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/auth/sessions',
    { Authorization => "Bearer $ENV{TOKEN}" },
    form => { except_current => 'true' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli sessions revoke --all-others
```

#### Response

200 OK:

```json
{ "ok": true, "revoked": 2 }
```

## Your account

What the system holds about your account, the recovery address a password-reset link is sent to, and the record of what your account has done.

### See your account

`GET https://api.mailmasker.org/api/v1/account/summary`

Your address, when the account was made, your recovery address and your roles.

- Who can use it: Any signed-in account
- Area: Your account
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/account-summary

`recovery_email` is where a "forgot password" link is sent, or null if you have none. `roles` are what the account may do; an ordinary account has just `user`.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/account/summary' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/account/summary',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "email": "you@example.com",
  "created_at": "2026-09-27 14:03:51.402117-05",
  "recovery_email": "personal@example.com",
  "active": true,
  "roles": ["user"]
}
```

### Set your recovery address

`POST https://api.mailmasker.org/api/v1/account/recovery-email`

Choose where a password-reset link is sent, or remove it.

- Who can use it: Any signed-in account
- Area: Your account
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/set-recovery-email

If you forget your password, the sign-in page sends a reset link to this address, so pick one outside this mail system. Without one, a reset by email isn't possible, though you can still change your password while signed in. Nothing is sent to check the address, so make sure it is right.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `recovery_email` | body | no | The address to use. An empty string or null removes it, and so does leaving it out. | `personal@example.com` |

#### Good to know

- Something that doesn't look like an address answers 400.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/account/recovery-email' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"recovery_email":"personal@example.com"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/account/recovery-email',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { recovery_email => 'personal@example.com' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true, "recovery_email": "personal@example.com" }
```

### See your activity log

`GET https://api.mailmasker.org/api/v1/audit/log`

What your account has done and when, the newest first.

- Who can use it: Any signed-in account
- Area: Your account
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/activity-log

Sign-ins and changes made through the fleet's services: ending a session, blocking an address, deleting a file, sending mail through this API and so on. Looking at things isn't recorded. Each entry says what was done (`action`), to what (`resource_type`, `resource_id`), by which service, and from which network address and program. At most 500 entries come back.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `user` | query | no | Only entries this account did. Anyone but yourself needs the audit.view permission. |  |
| `affecting` | query | no | Only entries that touched this account, including what a site admin did to it. Anyone but yourself needs the audit.view permission. |  |
| `since` | query | no | Only entries at or after this time, such as 2026-11-01 or 2026-11-01 16:00:00-06. | `2026-11-01` |
| `until` | query | no | Only entries at or before this time, written the same way. |  |
| `action` | query | no | Only entries with this action, such as auth.login or file.delete. |  |

#### Good to know

- Asking for someone else's entries without the audit.view permission answers 403.
- With audit.view and neither user nor affecting, you see everyone's entries.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/audit/log?since=2026-11-01' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/audit/log',
    { Authorization => "Bearer $ENV{TOKEN}" },
    form => { since => '2026-11-01' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli audit list --since 2026-11-01
```

#### Response

200 OK:

```json
[
  {
    "id": 18342,
    "occurred_at": "2026-11-03 21:42:10.123456-06",
    "actor_email": "you@example.com",
    "affected_user": "you@example.com",
    "jti": "7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f",
    "action": "recipient_access.block",
    "resource_type": "recipient_access",
    "resource_id": "shop@example.com",
    "source_service": "homelab-domain-admin",
    "ip_address": "203.0.113.7",
    "user_agent": "homelab-cli/0.1.0 (Linux 6.8.0-49-generic)",
    "detail": { "tier": "self_service", "action": "REJECT", "reason": "Sells my address" }
  },
  {
    "id": 18297,
    "occurred_at": "2026-11-03 21:40:02.654321-06",
    "actor_email": "you@example.com",
    "affected_user": "you@example.com",
    "jti": "7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f",
    "action": "auth.login",
    "resource_type": "user",
    "resource_id": "42",
    "source_service": "homelab-api",
    "ip_address": "203.0.113.7",
    "user_agent": "homelab-cli/0.1.0 (Linux 6.8.0-49-generic)",
    "detail": null
  }
]
```

## Your addresses and blocking

The addresses you can send from and receive at, and the ones you've blocked. Blocking an address rejects all mail sent to it, from anyone; it is how you retire an address you gave to a shop or a newsletter.

### List your addresses

`GET https://api.mailmasker.org/api/v1/domains/mail-aliases/mine`

Every address you can send from, and the ones you can only receive at.

- Who can use it: Any signed-in account
- Area: Your addresses and blocking
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/my-addresses

Your own login address first, then every extra address and whole domain routed to you. Under `send` are the ones you may also send from; under `receive_only` are ones whose sending is switched off, so mail reaches you but a send from them is refused.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/domains/mail-aliases/mine' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/domains/mail-aliases/mine',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli mail allowed-senders
```

#### Response

200 OK:

```json
{
  "send": {
    "addresses": ["you@example.com", "shop@example.com"],
    "domains": ["@example.com"]
  },
  "receive_only": {
    "addresses": [],
    "domains": []
  }
}
```

### List your blocked addresses

`GET https://api.mailmasker.org/api/v1/domains/recipient-access/mine`

The addresses you've blocked or allowed, alphabetically.

- Who can use it: Any signed-in account
- Area: Your addresses and blocking
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/blocked-list

Every block-or-allow entry you made yourself, whether from myaccount, homelab-cli or a link in an email. An entry whose `action` is `OK` is an allow; any other action is a block.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `q` | query | no | Only entries whose address contains this text, ignoring case. | `shop` |

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/domains/recipient-access/mine?q=shop' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/domains/recipient-access/mine',
    { Authorization => "Bearer $ENV{TOKEN}" },
    form => { q => 'shop' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli mail blocked --search shop
```

#### Response

200 OK:

```json
[
  {
    "recipient": "shop@example.com",
    "action": "REJECT",
    "reason": "blocked via inbox link",
    "created_by": "you@example.com",
    "user_email": "you@example.com",
    "created_at": "2026-11-03 21:42:10.123456-06",
    "updated_at": "2026-11-03 21:42:10.123456-06"
  }
]
```

### Block an address

`POST https://api.mailmasker.org/api/v1/domains/recipient-access/mine`

Reject all future mail sent to one of your own addresses.

- Who can use it: Any signed-in account
- Area: Your addresses and blocking
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/block-address

From now on, mail to this address is refused while it is being delivered, so the sender gets a rejection and you never see it. Blocking an address that is already blocked updates its reason.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `recipient` | body | yes | The address to block. It must be one of yours. | `shop@example.com` |
| `action` | body | yes | REJECT to block. (OK would make it an allow entry instead.) | `REJECT` |
| `reason` | body | no | Shown to whoever sends mail to it, in the rejection. | `This address is no longer in use` |

#### Good to know

- Your main (login) address can't be blocked, because that would cut off all of your mail; the answer is 400.
- An address you don't own is refused with 403.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/recipient-access/mine' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"recipient":"shop@example.com","action":"REJECT","reason":"This address is no longer in use"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/domains/recipient-access/mine',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { recipient => 'shop@example.com', action => 'REJECT', reason => 'This address is no longer in use' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli mail block shop@example.com --reason "This address is no longer in use"
```

#### Response

201 Created:

```json
{
  "recipient": "shop@example.com",
  "action": "REJECT",
  "reason": "This address is no longer in use",
  "created_by": "you@example.com",
  "user_email": "you@example.com",
  "created_at": "2026-11-03 21:42:10.123456-06",
  "updated_at": "2026-11-03 21:42:10.123456-06"
}
```

### Unblock an address

`DELETE https://api.mailmasker.org/api/v1/domains/recipient-access/mine/:recipient`

Take one address off your block list, so its mail is delivered again.

- Who can use it: Any signed-in account
- Area: Your addresses and blocking
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/unblock-address

Removes your own entry for this address. Only entries you made can be removed this way; one a site admin made stays.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `recipient` | path | yes | The address, exactly as your list shows it. | `shop@example.com` |

#### Good to know

- An address that isn't on your list answers 404.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/recipient-access/mine/shop%40example.com' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/recipient-access/mine/shop%40example.com',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli mail unblock shop@example.com
```

#### Response

200 OK:

```json
{ "ok": true }
```

### See your inbox-link setting

`GET https://api.mailmasker.org/api/v1/block-link/account`

Whether mail to you gets a "block this address" link, and how.

- Who can use it: Any signed-in account
- Area: Your addresses and blocking
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/block-link-setting

Incoming mail can carry a link that blocks the address it was sent to in one click. `enabled` is your own choice if you have made one, otherwise your domain's default (`domain_default`). `mode` is where the link goes: `header` (a List-Unsubscribe header), `body` (a line at the end of the message) or `both`.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/block-link/account' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/block-link/account',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli mail block-link show
```

#### Response

200 OK:

```json
{ "enabled": true, "mode": "both", "domain_default": false }
```

### Turn inbox links on or off

`PUT https://api.mailmasker.org/api/v1/block-link/account`

Choose whether your incoming mail carries a "block this address" link.

- Who can use it: Any signed-in account
- Area: Your addresses and blocking
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/block-link-set

Your own choice overrides your domain's default. Send `null` to drop your choice and follow the domain default again.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `enabled` | body | yes | true for links, false for none, null to follow the domain default. | `true` |

#### Examples

cURL:

```bash
curl -s -X PUT 'https://api.mailmasker.org/api/v1/block-link/account' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"enabled":true}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->put('https://api.mailmasker.org/api/v1/block-link/account',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { enabled => \1 })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli mail block-link enable
```

#### Response

200 OK:

```json
{ "enabled": true, "mode": "both", "domain_default": false }
```

## Mail

Read the mail in your mailbox and send mail as yourself, without an email program. Each call opens your own mailbox with your own sign-in, so it sees exactly what your email app sees.

### List your recent messages

`GET https://api.mailmasker.org/api/v1/mail/messages`

The sender, subject and date of the newest messages in one folder.

- Who can use it: Any signed-in account
- Area: Mail
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/mail-list

The most recent messages in a folder, the oldest of them first. Each `uid` is the message's lasting number in that folder; use it to read the message. Listing doesn't mark anything as read.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `mailbox` | query | no | The folder to list. INBOX if you leave it out. |  |
| `limit` | query | no | How many of the newest messages to return. 20 if you leave it out. | `10` |

#### Good to know

- A folder that doesn't exist answers 502, the same as when your mailbox can't be reached.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/mail/messages?limit=10' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/mail/messages',
    { Authorization => "Bearer $ENV{TOKEN}" },
    form => { limit => 10 })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli mail list --limit 10
```

#### Response

200 OK:

```json
[
  {
    "uid": "41",
    "from": "Alex <alex@example.com>",
    "subject": "Lunch on Friday?",
    "date": "Tue, 3 Nov 2026 12:05:44 -0600"
  },
  {
    "uid": "42",
    "from": "billing@example.com",
    "subject": "Your November invoice",
    "date": "Tue, 3 Nov 2026 21:42:10 -0600"
  }
]
```

### Read a message

`GET https://api.mailmasker.org/api/v1/mail/messages/:uid`

One message's sender, subject, date and plain-text body.

- Who can use it: Any signed-in account
- Area: Mail
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/mail-read

The body is the message's plain-text part. A message sent only as HTML comes back with an empty body, and attachments are left out. Reading a message marks it as read, as opening it in an email program does.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `uid` | path | yes | The message's number, from List your recent messages. | `42` |
| `mailbox` | query | no | The folder the message is in. INBOX if you leave it out. |  |

#### Good to know

- A number that isn't in that folder answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/mail/messages/42' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/mail/messages/42',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli mail read 42
```

#### Response

200 OK:

```json
{
  "from": "billing@example.com",
  "subject": "Your November invoice",
  "date": "Tue, 3 Nov 2026 21:42:10 -0600",
  "body": "Thanks for your order. Your invoice is attached."
}
```

### Send a message

`POST https://api.mailmasker.org/api/v1/mail/send`

Send a plain-text email from your own address, or another one you may send from.

- Who can use it: Any signed-in account
- Area: Mail
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/mail-send

The message goes out from your login address unless you name another in `from`, which must be one of the addresses you may send from (see List your addresses). It goes to one recipient, as plain text.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `to` | body | yes | The one address to send it to. | `alex@example.com` |
| `subject` | body | yes | The subject line. | `Lunch on Friday?` |
| `body` | body | yes | The message, as plain text. | `Are you free at noon?` |
| `from` | body | no | Send from this address instead of your login address. It may carry a name, as in `Shop <shop@example.com>`. |  |

#### Good to know

- Leaving out to, subject or body answers 400.
- A from address you may not send from is refused with 403.
- If the mail server won't take the message, the answer is 502.
- Each message sent is recorded in your activity log, with its sender, recipient and subject.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/mail/send' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"to":"alex@example.com","subject":"Lunch on Friday?","body":"Are you free at noon?"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/mail/send',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { to => 'alex@example.com', subject => 'Lunch on Friday?', body => 'Are you free at noon?' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli mail send --to alex@example.com --subject "Lunch on Friday?" --body "Are you free at noon?"
```

#### Response

200 OK:

```json
{ "ok": true }
```

### See your mail storage use

`GET https://api.mailmasker.org/api/v1/mail/usage`

How much space your mail takes, and your mailbox's limit, in bytes.

- Who can use it: Any signed-in account
- Area: Mail
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/mail-usage

`used_bytes` is how much your mail takes up and `limit_bytes` is the most your mailbox may hold, the same numbers myaccount's dashboard shows.

#### Good to know

- Both are 0 when no size limit is set for your mailbox.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/mail/usage' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/mail/usage',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "used_bytes": 734003200, "limit_bytes": 10737418240 }
```

## Drive

The files and folders you keep in Drive. Upload, list, download and organise them, share one by link, and bring back what you deleted from Trash. A big file goes up in pieces, so a dropped connection costs only the piece in flight. Unlike the Drive web page, a delete through the API is immediate and permanent unless you ask for Trash.

### List your files

`GET https://api.mailmasker.org/api/v1/drive/files`

The files in one folder, or at the top level, newest first.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-list-files

Only the files directly in that folder; its subfolders come from List your folders. Without `folder_id` you get the files at the top level, not every file you have. Files in Trash are left out.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `folder_id` | query | no | The folder to list. Leave it out for the top level. |  |

#### Good to know

- A folder that isn't yours, or doesn't exist, answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/drive/files' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/drive/files',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive list
```

#### Response

200 OK:

```json
[
  {
    "id": 42,
    "filename": "notes.txt",
    "size_bytes": 1532,
    "mime_type": "text/plain",
    "uploaded_at": "2026-11-03 21:42:10.123456-06",
    "folder_id": null
  },
  {
    "id": 41,
    "filename": "receipt.pdf",
    "size_bytes": 84210,
    "mime_type": "application/pdf",
    "uploaded_at": "2026-11-02 09:15:44.551203-06",
    "folder_id": null
  }
]
```

### Upload a file

`POST https://api.mailmasker.org/api/v1/drive/files`

Send a whole file in one request, as a form upload.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/drive-upload-file

The body is a multipart form, the kind a browser sends, with the file in a field named `file`; the name it has there becomes its name in Drive. Drive works out the real type from the contents, whatever the upload claims. Fine for everyday files; for a big one, or over a shaky connection, use Start a big upload instead. homelab-cli switches to that for files over 8 MiB.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `file` | body | yes | The file itself, as a multipart form field (with cURL, `-F file=@notes.txt`). |  |
| `folder_id` | body | no | A form field naming the folder to put it in. Leave it out for the top level. |  |
| `client_mtime` | body | no | A form field with the file's own date, in seconds since 1970, so a search by date finds it (or client_mtime_ms, in milliseconds). A date before 1990, or more than a day ahead, is ignored.  |  |

#### Good to know

- With no file field the answer is 400, and a folder that isn't yours answers 404.
- An upload that would take you past your storage limit is refused with 413.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/files' \
  -H "Authorization: Bearer $TOKEN" \
  -F file=@notes.txt
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/files',
    { Authorization => "Bearer $ENV{TOKEN}" },
    form => { file => { file => 'notes.txt' } })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive upload ./notes.txt
```

#### Response

201 Created:

```json
{
  "id": 42,
  "filename": "notes.txt",
  "size_bytes": 1532,
  "mime_type": "text/plain",
  "uploaded_at": "2026-11-03 21:42:10.123456-06",
  "content_created_at": "2026-10-30 08:12:03-06",
  "folder_id": null
}
```

### Download a file

`GET https://api.mailmasker.org/api/v1/drive/files/:id`

The file's contents, exactly as uploaded.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-download-file

Answers with the file's bytes, sent as the file's own type. The api passes on the type but not the file's name, so save it under the name your file list shows.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The file's id, from List your files. | `42` |

#### Good to know

- A file that isn't yours answers 404, the same as one that doesn't exist, and so does a file in Trash until it is restored.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/drive/files/42' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/drive/files/42',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive download 42 --output notes.txt
```

#### Response

200 OK:

```text
(the file's contents)
```

### Delete a file

`DELETE https://api.mailmasker.org/api/v1/drive/files/:id`

Delete one file at once and for good, or move it to Trash.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-delete-file

Unlike the Drive web page, this deletes the file immediately and for good. Add `soft=1` to move it to Trash instead, where it can be restored; `trashed` in the answer says which happened.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The file's id. | `42` |
| `soft` | query | no | 1 to move the file to Trash instead of deleting it for good. |  |

#### Good to know

- Any link you shared for the file stops working.
- A file that isn't yours, doesn't exist or is already in Trash answers 404.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/drive/files/42' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/drive/files/42',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive delete 42
```

#### Response

200 OK:

```json
{ "ok": true, "trashed": false }
```

### List your folders

`GET https://api.mailmasker.org/api/v1/drive/folders`

The folders inside one folder, or at the top level, by name.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-list-folders

One level only: the folders directly inside the one you name, not their subfolders. Folders in Trash are left out. The parameter here is `parent_id`, not `folder_id`.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `parent_id` | query | no | The folder whose subfolders you want. Leave it out for the top level. |  |

#### Good to know

- A folder that isn't yours, or doesn't exist, answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/drive/folders' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/drive/folders',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive list
```

#### Response

200 OK:

```json
[
  { "id": 7, "name": "Photos", "parent_folder_id": null },
  { "id": 9, "name": "Receipts", "parent_folder_id": null }
]
```

### Create a folder

`POST https://api.mailmasker.org/api/v1/drive/folders`

Make a new folder, at the top level or inside another.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/drive-create-folder

Folders nest as deep as you like, but two folders in the same place can't have the same name.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `name` | body | yes | The new folder's name. | `Receipts` |
| `parent_folder_id` | body | no | The folder to make it in. Leave it out, or send null, for the top level. | `7` |

#### Good to know

- A missing name answers 400.
- A folder of that name already in the same place answers 409, and so does a parent folder that isn't yours.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/folders' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"Receipts","parent_folder_id":7}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/folders',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { name => 'Receipts', parent_folder_id => 7 })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive mkdir Receipts --parent 7
```

#### Response

201 Created:

```json
{ "id": 9, "name": "Receipts", "parent_folder_id": 7 }
```

### Delete a folder

`DELETE https://api.mailmasker.org/api/v1/drive/folders/:id`

Delete a folder and everything inside it, for good or to Trash.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-delete-folder

Every file and folder inside it goes too, however deep. Unlike the Drive web page, this is immediate and for good; add `soft=1` to move the whole folder to Trash instead, where it can be restored in one go.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The folder's id. | `7` |
| `soft` | query | no | 1 to move the folder to Trash instead of deleting it for good. |  |

#### Good to know

- A folder that isn't yours, doesn't exist or is already in Trash answers 404.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/drive/folders/7' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/drive/folders/7',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive rmdir 7
```

#### Response

200 OK:

```json
{ "ok": true, "trashed": false }
```

### See your storage use

`GET https://api.mailmasker.org/api/v1/drive/usage`

How much space your files take, and your limit, in bytes.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-usage

`used_bytes` counts your files outside Trash. `limit_bytes` is your limit, 1 TiB unless a site admin has set another for you. An upload that would take you past it is refused.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/drive/usage' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/drive/usage',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "used_bytes": 2147483648, "limit_bytes": 1099511627776 }
```

### Start a big upload

`POST https://api.mailmasker.org/api/v1/drive/uploads`

Open an upload session, to send a big file in pieces you can resume.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/drive-upload-start

Open a session here, then send the file in order, one piece at a time, with Send a piece of a big upload. If the connection drops, Check a big upload tells you where to carry on. `chunk_size` is a suggested piece size; any size works. The file appears in Drive when its last byte arrives. homelab-cli uploads this way for files over 8 MiB.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `filename` | body | yes | The file's name in Drive. Only the last part of a path is kept. | `backup.iso` |
| `total_size` | body | yes | The file's exact size, in bytes. | `4294967296` |
| `folder_id` | body | no | The folder to put it in. Leave it out for the top level. |  |

#### Good to know

- An empty file (total_size 0) is finished at once, and the answer has done set to true and its file_id.
- A file over 50 GiB, or one that won't fit in your storage limit, is refused with 413.
- You can have up to 100 uploads open at once; one more answers 429. An upload left untouched for 24 hours is thrown away.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/uploads' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"filename":"backup.iso","total_size":4294967296}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/uploads',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { filename => 'backup.iso', total_size => 4294967296 })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive upload ./backup.iso
```

#### Response

201 Created:

```json
{
  "upload_id": "0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f",
  "offset": 0,
  "chunk_size": 8388608
}
```

### Check a big upload

`GET https://api.mailmasker.org/api/v1/drive/uploads/:id`

How much of an upload has arrived, so you know where to carry on.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-upload-status

`offset` is how many bytes Drive has safely stored, so the next piece starts there. `state` is `open` while bytes are still to come, `completed` once the file is in Drive (the answer then has its `file_id`), or `aborted` if Drive could not store it, in which case start again. Checking also keeps an open upload from being thrown away as abandoned. homelab-cli does this when you run the same upload again after an interruption.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The upload_id from Start a big upload. | `0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f` |

#### Good to know

- An upload that isn't yours, or no longer exists, answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/drive/uploads/0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/drive/uploads/0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive upload ./backup.iso
```

#### Response

200 OK:

```json
{
  "upload_id": "0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f",
  "state": "open",
  "offset": 16777216,
  "total_size": 4294967296,
  "filename": "backup.iso",
  "chunk_size": 8388608
}
```

### Send a piece of a big upload

`PATCH https://api.mailmasker.org/api/v1/drive/uploads/:id`

Add the next piece of the file to an open upload.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-upload-piece

The body is the raw bytes of the piece, not JSON, and an `Upload-Offset` header says where in the file the piece starts, which must be exactly how much Drive already has. The answer's `offset` is where the next piece starts. When the last byte lands, `done` is true and `file_id` is the new file.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The upload_id from Start a big upload. | `0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f` |
| `chunk` | body | yes | The next piece of the file, as the whole raw request body (send it as application/octet-stream), with an Upload-Offset header giving the byte it starts at.  |  |

#### Good to know

- A missing or malformed Upload-Offset header answers 400.
- A piece that doesn't start where Drive's copy ends answers 409 with the true offset, so carry on from there. That is how a resend after a dropped connection sorts itself out.
- A piece that would run past the size you declared answers 409 too, and so does one sent to a finished upload, whose answer includes its file_id.

#### Examples

cURL:

```bash
curl -s -X PATCH 'https://api.mailmasker.org/api/v1/drive/uploads/0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Upload-Offset: 0' \
  -H 'Content-Type: application/octet-stream' \
  --data-binary @piece-1.bin
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::File qw(path);
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->patch('https://api.mailmasker.org/api/v1/drive/uploads/0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f',
    { Authorization => "Bearer $ENV{TOKEN}", 'Upload-Offset' => 0,
      'Content-Type' => 'application/octet-stream' },
    path('piece-1.bin')->slurp)->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive upload ./backup.iso
```

#### Response

200 OK:

```json
{ "offset": 25165824, "done": false }
```

### Cancel a big upload

`DELETE https://api.mailmasker.org/api/v1/drive/uploads/:id`

Give up on an unfinished upload and throw away what was sent.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-upload-cancel

Drops the bytes sent so far and closes the session. An upload you just abandon is cleared away after 24 hours anyway; this does it now.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The upload_id from Start a big upload. | `0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f` |

#### Good to know

- A finished upload can't be cancelled (409), because it is a file now; delete the file instead.
- An upload that isn't yours, or no longer exists, answers 404.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/drive/uploads/0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/drive/uploads/0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true }
```

### Combine files into one

`POST https://api.mailmasker.org/api/v1/drive/append-jobs`

Join several files end to end into a new file, in the order you give.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 202 Accepted when it works
- Page: https://developer.mailmasker.org/drive-combine

For putting a file back together from its pieces, such as `backup.iso.001`, `backup.iso.002` and so on. The work happens in the background, so the answer is a job `id` to follow with See how a combine is going. The pieces are kept, and the new file lands next to the first piece unless you say otherwise.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `file_ids` | body | yes | At least two of your file ids, in the order their bytes should go. | `[41,42,43]` |
| `output_name` | body | no | The new file's name. Left out, it is the first piece's name without its piece number, so backup.iso.001 gives backup.iso.  | `backup.iso` |
| `folder_id` | body | no | The folder to put the new file in. Left out, it goes in the first piece's folder. |  |

#### Good to know

- Every piece must be one of your files and not in Trash. If any isn't, nothing starts and the answer is 400.
- The new file counts against your storage limit on top of the pieces; if it won't fit, the answer is 413.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/append-jobs' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"file_ids":[41,42,43],"output_name":"backup.iso"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/append-jobs',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { file_ids => [41, 42, 43], output_name => 'backup.iso' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive append 41 42 43 --output backup.iso
```

#### Response

202 Accepted:

```json
{ "id": 12, "output_name": "backup.iso" }
```

### See how a combine is going

`GET https://api.mailmasker.org/api/v1/drive/append-jobs/:id`

Whether a combine has finished, and how far along it is.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-combine-status

`state` goes from `pending` to `processing` and `finalizing`, then ends as `completed` or `failed`. While it runs, `received_bytes` of `total_bytes` have been written (`total_bytes` is null until it starts). Once it completes, `file_id` is the new file; if it fails, `error` says why.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The job id from Combine files into one. | `12` |

#### Good to know

- A job that isn't yours, or doesn't exist, answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/drive/append-jobs/12' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/drive/append-jobs/12',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "id": 12,
  "state": "processing",
  "output_name": "backup.iso",
  "received_bytes": 1073741824,
  "total_bytes": 4294967296,
  "file_id": null,
  "error": null
}
```

### Zip files and folders

`POST https://api.mailmasker.org/api/v1/drive/zip-jobs`

Build one zip of the files and folders you pick, saved into your Archives folder.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/drive-zip

The zip is built in the background and saved as a new file in a folder called Archives at your top level, made the first time it is needed. A folder goes in whole, subfolders and all. The answer is a job `id` to follow with See if a zip is ready, and the name and place the finished file will have.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `file_ids` | body | no | Ids of files to put in the zip. Give this, folder_ids, or both. | `[41,42]` |
| `folder_ids` | body | no | Ids of folders to put in the zip, with everything inside them. | `[7]` |

#### Good to know

- Ids that aren't yours, and anything in Trash, are quietly left out. With no ids, or nothing left to zip, the answer is 400.
- Two files with the same name both go in, one of them with a number added, such as notes (01).txt.
- If the service that builds zips is down, the answer is 502 or 504; try again later.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/zip-jobs' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"file_ids":[41,42],"folder_ids":[7]}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/zip-jobs',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { file_ids => [41, 42], folder_ids => [7] })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

201 Created:

```json
{
  "id": 318,
  "output_name": "drive-export-you-example-com-1793763730.zip",
  "dest_path": "Archives/drive-export-you-example-com-1793763730.zip"
}
```

### See if a zip is ready

`GET https://api.mailmasker.org/api/v1/drive/zip-jobs/:id`

Whether a zip has been built and saved, and where it is.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-zip-status

`state` is `queued`, then `building` (with `progress_current` of `progress_total` files done), then `delivering` while it is saved into Archives. It ends as `completed`, with the new file's `file_id`, or `failed`, with the reason in `error`.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The job id from Zip files and folders. | `318` |

#### Good to know

- A job that isn't yours, or doesn't exist, answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/drive/zip-jobs/318' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/drive/zip-jobs/318',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "id": 318,
  "state": "completed",
  "output_name": "drive-export-you-example-com-1793763730.zip",
  "dest_path": "Archives/drive-export-you-example-com-1793763730.zip",
  "file_id": 57
}
```

### Delete several files and folders at once

`POST https://api.mailmasker.org/api/v1/drive/bulk/delete`

Delete a selection of files and folders in one go, for good.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-delete-many

Through the API this is immediate and permanent; there is no Trash option here, so to keep things recoverable delete them one at a time with `soft=1`. Folders go first, with everything inside them, so a listed file that was in one of them comes back under `not_found`. Every id is reported, and one problem doesn't stop the rest.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `file_ids` | body | no | Ids of files to delete. | `[41,42]` |
| `folder_ids` | body | no | Ids of folders to delete, with everything inside them. | `[7]` |

#### Good to know

- An id that isn't yours counts as not found, the same as one that doesn't exist.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/bulk/delete' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"file_ids":[41,42],"folder_ids":[7]}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/bulk/delete',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { file_ids => [41, 42], folder_ids => [7] })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "files": { "deleted": [41, 42], "not_found": [] },
  "folders": { "deleted": [7], "not_found": [] },
  "counts": { "deleted": 3, "not_found": 0 }
}
```

### List what's in Trash

`GET https://api.mailmasker.org/api/v1/drive/trash`

Every file and folder you've deleted and can still get back.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-trash

Newest first. Things you delete wait in Trash until you restore them, delete them for good, or empty Trash; after a while (30 days unless the server says otherwise) Drive empties them itself.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/drive/trash' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/drive/trash',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive trash
```

#### Response

200 OK:

```json
{
  "files": [
    { "id": 42, "filename": "old-notes.txt", "size_bytes": 1532, "mime_type": "text/plain", "deleted_at_display": "2026-11-03 21:42:10 CST" }
  ],
  "folders": [
    { "id": 7, "name": "Old photos", "deleted_at_display": "2026-11-02 09:15:00 CST" }
  ]
}
```

### Restore a file from Trash

`POST https://api.mailmasker.org/api/v1/drive/files/:id/restore`

Bring a file back out of Trash, to the folder it was in.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-restore-file

The file goes back where it was. If its folder is in Trash too, that folder and any above it come back as well, so the file has somewhere to land; their other contents stay in Trash. Links you shared for it work again.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The file's id. | `42` |

#### Good to know

- A file that isn't in your Trash answers 404.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/files/42/restore' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/files/42/restore',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive restore 42
```

#### Response

200 OK:

```json
{ "ok": true }
```

### Restore a folder from Trash

`POST https://api.mailmasker.org/api/v1/drive/folders/:id/restore`

Bring a folder back out of Trash, with everything inside it.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-restore-folder

The folder goes back where it was, with all its files and subfolders. Any folders above it that are in Trash come back too.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The folder's id. | `7` |

#### Good to know

- A folder that isn't in your Trash answers 404.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/folders/7/restore' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/folders/7/restore',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive restore 7 --folder
```

#### Response

200 OK:

```json
{ "ok": true }
```

### Delete a file in Trash forever

`POST https://api.mailmasker.org/api/v1/drive/files/:id/purge`

Permanently delete one file that is in Trash.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-purge-file

Only for a file already in Trash, and it can't be undone. To delete a file that isn't in Trash, use Delete a file.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The file's id. | `42` |

#### Good to know

- A file that isn't in your Trash answers 404.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/files/42/purge' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/files/42/purge',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true }
```

### Delete a folder in Trash forever

`POST https://api.mailmasker.org/api/v1/drive/folders/:id/purge`

Permanently delete one folder that is in Trash, with everything inside it.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-purge-folder

Only for a folder already in Trash. All its files and subfolders go with it, and it can't be undone.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The folder's id. | `7` |

#### Good to know

- A folder that isn't in your Trash answers 404.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/folders/7/purge' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/folders/7/purge',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true }
```

### Empty your Trash

`POST https://api.mailmasker.org/api/v1/drive/trash/empty`

Permanently delete everything in your Trash.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-empty-trash

Every file and folder in your Trash is deleted for good, and the answer says how many of each. Trash also empties itself as it goes, deleting anything that has been there for 30 days.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/trash/empty' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/trash/empty',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true, "purged": { "files": 3, "folders": 1 } }
```

### Keep a file out of search

`POST https://api.mailmasker.org/api/v1/drive/files/:id/private`

Stop a file turning up in search results, or let it show again.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-file-private

Private means only that the file stops coming back from a search. It isn't hidden or deleted; it stays in its folder and still downloads. Search catches up on its next indexing pass, not at once, as `notice` says.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The file's id. | `42` |
| `private` | query | no | 0 (or false, no, off) to let search find it again. Leave it out, or send 1, to make it private. |  |

#### Good to know

- A file inside a private folder stays out of search even when the file itself isn't marked.
- A file that isn't yours, or is in Trash, answers 404.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/files/42/private' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/files/42/private',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive private 42
```

#### Response

200 OK:

```json
{
  "id": 42,
  "name": "notes.txt",
  "kind": "file",
  "private": true,
  "notice": "Marked private. It stops appearing in search on the next index pass; it is not hidden, deleted or unindexed."
}
```

### Keep a folder out of search

`POST https://api.mailmasker.org/api/v1/drive/folders/:id/private`

Stop everything in a folder turning up in search results, or let it show again.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-folder-private

Covers everything inside the folder, however deep. As with a file, nothing is hidden or deleted; it all just stops coming back from a search, from the next indexing pass on.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The folder's id. | `7` |
| `private` | query | no | 0 (or false, no, off) to let search find it again. Leave it out, or send 1, to make it private. |  |

#### Good to know

- Turning it off lifts only this folder's own setting. Anything still under another private folder, or marked private itself, stays out of search.
- A folder that isn't yours, or is in Trash, answers 404.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/folders/7/private' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/folders/7/private',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli drive private 7 --folder
```

#### Response

200 OK:

```json
{
  "id": 7,
  "name": "Taxes",
  "kind": "folder",
  "private": true,
  "notice": "Marked private. It stops appearing in search on the next index pass; it is not hidden, deleted or unindexed."
}
```

### Share a file

`POST https://api.mailmasker.org/api/v1/drive/files/:id/share`

Make a link to one of your files, for one person or for anyone.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/drive-share-file

Every share is a link. Leave out `shared_with` for a public link that anyone who has it can open, to pass on yourself. Name a person and the link opens only for them, signed in as that address (and for you); it also shows in their list of shares, and they are emailed the link. `notice` says plainly what happened, including when no email went out.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The file's id. | `42` |
| `shared_with` | query | no | The address of the person to share with. Leave it out for a public link. | `friend@example.com` |
| `expires_days` | query | no | The link stops working after this many days (1 to 9999). Leave it out for a link that lasts until you revoke it. |  |

#### Good to know

- Sharing with an address that has no account still makes the share, but sends no email (emailed is false), and the link opens only once someone signs in as that address. For anyone without an account, make a public link.
- You can't share with yourself (400). A file that isn't yours, or is in Trash, answers 404.
- While the file is in Trash its link stops working, and deleting it for good ends the share.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/files/42/share?shared_with=friend%40example.com' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/files/42/share',
    { Authorization => "Bearer $ENV{TOKEN}" },
    form => { shared_with => 'friend@example.com' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

201 Created:

```json
{
  "ok": true,
  "id": 15,
  "token": "5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e",
  "url": "https://drive.example.com/s/5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e",
  "target": "file",
  "label": "notes.txt",
  "shared_with": "friend@example.com",
  "public": false,
  "emailed": true,
  "notice": "Shared with friend@example.com and emailed them the link."
}
```

### Share a folder

`POST https://api.mailmasker.org/api/v1/drive/folders/:id/share`

Make a link to one of your folders, for one person or for anyone.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/drive-share-folder

The link opens the whole folder, subfolders included, to browse and download, but nothing outside it. As with a file, a public link opens for anyone who has it, and naming a person in `shared_with` makes it open only for them, signed in, lists it for them and emails them the link.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The folder's id. | `7` |
| `shared_with` | query | no | The address of the person to share with. Leave it out for a public link. |  |
| `expires_days` | query | no | The link stops working after this many days (1 to 9999). Leave it out for a link that lasts until you revoke it. | `7` |

#### Good to know

- What you add to the folder later is shared too, and anything inside it you move to Trash drops out of the share.
- You can't share with yourself (400). A folder that isn't yours, or is in Trash, answers 404.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/folders/7/share?expires_days=7' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/folders/7/share',
    { Authorization => "Bearer $ENV{TOKEN}" },
    form => { expires_days => 7 })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

201 Created:

```json
{
  "ok": true,
  "id": 16,
  "token": "0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e",
  "url": "https://drive.example.com/s/0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e",
  "target": "folder",
  "label": "Photos",
  "shared_with": null,
  "public": true,
  "notice": "Public link created. Anyone with the link can open it."
}
```

### List your shares

`GET https://api.mailmasker.org/api/v1/drive/shares`

What you have shared, and what others have shared with you, newest first.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-shares

Two lists in one answer. `shared_by_me` is every share you made and haven't revoked, with who it is for (null means anyone with the link) and how often it has been opened. `shared_with_me` is what others have shared with your address and still works. Shares of things in Trash are left out of both.

#### Good to know

- Your own list still shows shares whose time has run out; check expires_at.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/drive/shares' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/drive/shares',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "shared_by_me": [
    {
      "id": 16,
      "token": "0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e",
      "shared_with_email": null,
      "created_at": "2026-11-03 21:42:10.123456-06",
      "expires_at": "2026-11-10 21:42:10.123456-06",
      "access_count": 4,
      "accessed_at": "2026-11-03 22:05:31.402215-06",
      "label": "Photos",
      "target": "folder",
      "url": "https://drive.example.com/s/0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e"
    },
    {
      "id": 15,
      "token": "5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e",
      "shared_with_email": "friend@example.com",
      "created_at": "2026-11-03 21:40:02.918377-06",
      "expires_at": null,
      "access_count": 0,
      "accessed_at": null,
      "label": "notes.txt",
      "target": "file",
      "url": "https://drive.example.com/s/5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e"
    }
  ],
  "shared_with_me": [
    {
      "id": 11,
      "token": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00",
      "owner_email": "friend@example.com",
      "created_at": "2026-11-01 14:03:55.120934-06",
      "expires_at": null,
      "label": "Recipes",
      "target": "folder",
      "url": "https://drive.example.com/s/c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00"
    }
  ]
}
```

### Revoke a share

`POST https://api.mailmasker.org/api/v1/drive/shares/:id/revoke`

Turn off a share's link, for everyone who has it.

- Who can use it: Any signed-in account
- Area: Drive
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/drive-revoke-share

The link stops working at once, and the share drops off your list and the other person's. It can't be turned back on; share again for a new link.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The share's id, from List your shares. | `15` |

#### Good to know

- A share that isn't yours, or is already revoked, answers 404.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/shares/15/revoke' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/drive/shares/15/revoke',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true, "id": 15 }
```

## Search

Search your email and your files together. It looks through a copy of both that is kept up to date in the background, so something that has only just arrived can take a little while to show up.

### Search your mail and files

`GET https://api.mailmasker.org/api/v1/search`

Find email and files by words, people, dates or type, in one search.

- Who can use it: Any signed-in account
- Area: Search
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/search

Mail matches on its subject and text, files on their name and text; with `q` the best matches come first, otherwise the newest. Results come in two lists, `mail` and `drive`, each up to `limit` long. Files someone has shared with you are included, with their address in `shared_by`. `filters` shows how your request was read, and `indexed` is false until something of yours has been indexed.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `q` | query | no | Words to find. Put a phrase in double quotes, use OR between words for either one, and a minus before a word to leave it out. | `invoice` |
| `person` | query | no | A name or address. Finds mail from or to them, and photos they are tagged in. |  |
| `from` | query | no | Only items dated on or after this day, as YYYY-MM-DD. |  |
| `to` | query | no | Only items dated on or before this day, as YYYY-MM-DD. |  |
| `source` | query | no | both (the default), mail or drive. |  |
| `mailbox` | query | no | Only mail in this folder, spelled as the results' `mailbox` shows it, such as INBOX. |  |
| `has_attachments` | query | no | 1 for only mail that has attachments. |  |
| `mime` | query | no | Only files of this type, such as image or application/pdf. |  |
| `type_in` | query | no | Only these types, repeated for more than one. A group (cat:pictures), a whole kind (image/*) or one type (image/png). For mail it matches the attachments. |  |
| `type_ex` | query | no | Leave these types out, written the same way as type_in. |  |
| `tag` | query | no | Only photos with this tag, or a tag filed under it. |  |
| `shared` | query | no | What to do with files others have shared with you. include (the default), exclude, or only. |  |
| `in_trash` | query | no | 1 to search your deleted files instead. |  |
| `min_size` | query | no | Only files of at least this many bytes. |  |
| `max_size` | query | no | Only files of at most this many bytes. |  |
| `limit` | query | no | The most results from mail, and from files. 50 if you leave it out, 200 at most. |  |
| `offset` | query | no | Skip this many results first, to get the next page. |  |

#### Good to know

- With no words and no filter, nothing is searched and both lists come back empty. Choosing only a `source` doesn't count as a filter.
- A mail result's `message_id` is the search's own number for it, not the `uid` that Read a message takes.
- Files you have kept out of search (marked private in drive, alone or by their folder) never come back.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/search?q=invoice' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/search',
    { Authorization => "Bearer $ENV{TOKEN}" },
    form => { q => 'invoice' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli search invoice
```

#### Response

200 OK:

```json
{
  "account": "you@example.com",
  "indexed": true,
  "filters": {
    "q": "invoice", "person": "", "from": "", "to": "", "mailbox": "",
    "mime": "", "tag": "", "shared": "include", "source": "both",
    "type_in": [], "type_ex": [], "in_trash": 0, "has_attachments": 0,
    "limit": 50, "offset": 0
  },
  "mail": [
    {
      "message_id": 4182,
      "subject": "Your November invoice",
      "sent_at": "2026-11-03 21:42:10-06",
      "received_at": "2026-11-03 21:42:12-06",
      "size_bytes": 48211,
      "has_attachments": 1,
      "is_seen": 1,
      "mailbox": "INBOX",
      "rank": 0.4,
      "from_addr": "billing@example.com",
      "from_name": "Example Billing",
      "snippet": "Thanks for your order. Your invoice for November is attached."
    }
  ],
  "drive": [
    {
      "drive_item_id": 913,
      "drive_file_id": 2207,
      "file_uuid": "6f1c2a9e-3b4d-4c5e-9f70-1a2b3c4d5e6f",
      "file_name": "invoice-october.pdf",
      "folder_path": "Bills",
      "size_bytes": 88340,
      "uploaded_at": "2026-11-02 08:15:30.402113-06",
      "content_created_at": null,
      "deleted_at": null,
      "mime_type": "application/pdf",
      "rank": 0.2,
      "snippet": "Invoice for October. Amount due"
    }
  ]
}
```

## Invites

Invite someone to make an account here. An invite is a link that works for one sign-up, emailed to them. How many you may have waiting, and send in a day, is limited.

### Invite someone

`POST https://api.mailmasker.org/api/v1/invites`

Email someone a link they can use to make an account.

- Who can use it: Any signed-in account
- Area: Invites
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/invite-send

Makes a one-time sign-up link and emails it to them, with your note if you add one. The answer holds the link itself as `url`, so you can also pass it on another way. It stops working at `expires_at`.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `recipient_email` | body | yes | Their email address, where the link is sent. | `friend@example.com` |
| `message` | body | no | A note from you, put in the email and shown on the sign-up page. | `Come and join me here.` |
| `channel` | body | no | cli (the default) or web, and the email is sent for you. roundcube_plugin sends no email, leaving the link for you to deliver. |  |

#### Good to know

- Past your limit of waiting invites, or of invites sent in the last day, the answer is 429 (see See your invite limits).
- A second invite to someone who already has a pending one from you answers 409. An address that doesn't look like one answers 400.
- If the email can't be sent, the invite is still made. The answer adds a `warning`, and the link in `url` still works.
- Someone whose address is on one of this system's own mail domains can't use an invite; the sign-up page turns them away.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/invites' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"recipient_email":"friend@example.com","message":"Come and join me here."}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/invites',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { recipient_email => 'friend@example.com', message => 'Come and join me here.' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli invite send --to friend@example.com --message "Come and join me here."
```

#### Response

201 Created:

```json
{
  "id": 12,
  "token": "f00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeed",
  "sender_email": "you@example.com",
  "recipient_email": "friend@example.com",
  "channel": "cli",
  "message": "Come and join me here.",
  "status": "pending",
  "created_at": "2026-11-03 21:42:10.123456-06",
  "expires_at": "2026-11-17 21:42:10.123456-06",
  "accepted_at": null,
  "accepted_user_email": null,
  "revoked_at": null,
  "revoked_by_email": null,
  "url": "https://invite.example.com/invite/f00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeed"
}
```

### List your invites

`GET https://api.mailmasker.org/api/v1/invites`

Every invite you have sent, newest first, and what became of each.

- Who can use it: Any signed-in account
- Area: Invites
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/invite-list

`status` is pending, accepted (the link was used), revoked (it was withdrawn) or expired. For an accepted invite, `accepted_user_email` is the account it made.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `all` | query | no | true or 1 for everyone's invites, not only yours. Site admins only; anyone else gets 403. |  |

#### Good to know

- An invite past its `expires_at` says expired, and no longer counts toward your limit of waiting invites.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/invites' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/invites',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli invite list
```

#### Response

200 OK:

```json
[
  {
    "id": 12,
    "token": "f00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeed",
    "sender_email": "you@example.com",
    "recipient_email": "friend@example.com",
    "channel": "cli",
    "message": "Come and join me here.",
    "status": "pending",
    "created_at": "2026-11-03 21:42:10.123456-06",
    "expires_at": "2026-11-17 21:42:10.123456-06",
    "accepted_at": null,
    "accepted_user_email": null,
    "revoked_at": null,
    "revoked_by_email": null
  }
]
```

### Withdraw an invite

`DELETE https://api.mailmasker.org/api/v1/invites/:id`

Cancel a pending invite, so its link stops working.

- Who can use it: Any signed-in account
- Area: Invites
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/invite-revoke

The invite stays on your list, as revoked, and no longer counts toward your limit of waiting invites. Only a pending invite can be withdrawn.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The invite's `id`, from List your invites. | `12` |
| `user` | query | no | The sender's address, to withdraw an invite someone else sent. Site admins only; anyone else gets 403. |  |

#### Good to know

- An invite that isn't yours, or isn't pending any more, answers 404.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/invites/12' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/invites/12',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli invite revoke 12
```

#### Response

200 OK:

```json
{ "ok": true }
```

### See your invite limits

`GET https://api.mailmasker.org/api/v1/invites/quota`

How many invites you may have waiting at once, and send in a day.

- Who can use it: Any signed-in account
- Area: Invites
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/invite-quota-mine

`max_pending` is how many unused invites you may have out at once, and `max_per_day` how many you may send in any 24 hours. They are the site's defaults unless a site admin has set your own.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/invites/quota' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/invites/quota',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli invite quota show
```

#### Response

200 OK:

```json
{ "max_pending": 10, "max_per_day": 5 }
```

### See someone's invite limits

`GET https://api.mailmasker.org/api/v1/invites/quota/:sender_email`

The invite limits that apply to one sender.

- Who can use it: Site admins
- Area: Invites
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/invite-quota-show

Their own limits if a site admin has set them, otherwise the site's defaults. The answer looks the same either way.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `sender_email` | path | yes | The sender's address. | `alex@example.com` |

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/invites/quota/alex%40example.com' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/invites/quota/alex%40example.com',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli invite quota show --user alex@example.com
```

#### Response

200 OK:

```json
{ "max_pending": 20, "max_per_day": 10 }
```

### Set someone's invite limits

`PUT https://api.mailmasker.org/api/v1/invites/quota/:sender_email`

Give one sender their own invite limits in place of the site's defaults.

- Who can use it: Site admins
- Area: Invites
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/invite-quota-set

Replaces any limits the sender already had. There is no call to remove them and go back to following the defaults.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `sender_email` | path | yes | The sender's address. | `alex@example.com` |
| `max_pending` | body | yes | How many unused invites they may have out at once. | `20` |
| `max_per_day` | body | yes | How many they may send in any 24 hours. | `10` |

#### Good to know

- Leaving out either number answers 400.

#### Examples

cURL:

```bash
curl -s -X PUT 'https://api.mailmasker.org/api/v1/invites/quota/alex%40example.com' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"max_pending":20,"max_per_day":10}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->put('https://api.mailmasker.org/api/v1/invites/quota/alex%40example.com',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { max_pending => 20, max_per_day => 10 })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli invite quota set --user alex@example.com --max-pending 20 --max-per-day 10
```

#### Response

200 OK:

```json
{
  "sender_email": "alex@example.com",
  "max_pending": 20,
  "max_per_day": 10,
  "updated_at": "2026-11-03 21:42:10.123456-06",
  "updated_by_email": "you@example.com"
}
```

## Background jobs

Work too slow to finish while you wait, such as building one zip file out of many files. Drive starts these for you (see "Zip files and folders"); here you can follow your jobs and download what they made. Finished jobs are cleared away after a while, a day unless the site has set another time.

### List your jobs

`GET https://api.mailmasker.org/api/v1/jobs`

Your 50 most recent background jobs, newest first.

- Who can use it: Any signed-in account
- Area: Background jobs
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/job-list

A job's `state` is pending (waiting its turn), running, completed or failed. While it runs, `progress_current` of `progress_total` says how far it has got; a failed job says why in `error_message`. A finished job's file is `output_name`, `output_size_bytes` long.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `all` | query | no | 1 for everyone's jobs, not only yours. Site admins only; anyone else gets 403. |  |
| `type` | query | no | Only jobs of this kind, such as zip. |  |
| `state` | query | no | Only jobs in this state, such as failed. |  |

#### Good to know

- `started_at` moves on each time a running job makes progress, so it can be later than when the job really began.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/jobs' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/jobs',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli jobs list
```

#### Response

200 OK:

```json
[
  {
    "id": 42,
    "type": "zip",
    "state": "completed",
    "user_email": "you@example.com",
    "output_name": "photos.zip",
    "output_size_bytes": 5242880,
    "progress_current": 2,
    "progress_total": 2,
    "error_message": null,
    "created_at": "2026-11-03 21:42:10.123456-06",
    "started_at": "2026-11-03 21:42:19.004211-06",
    "completed_at": "2026-11-03 21:42:31.870442-06"
  }
]
```

### Check on a job

`GET https://api.mailmasker.org/api/v1/jobs/:id`

One job's state and progress, and its file's name and size once it is done.

- Who can use it: Any signed-in account
- Area: Background jobs
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/job-show

The same fields as List your jobs, for one job. You can look up your own jobs; a site admin can look up anyone's.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The job's `id`. | `42` |

#### Good to know

- A job that isn't yours, or has been cleared away, answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/jobs/42' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/jobs/42',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli jobs show 42
```

#### Response

200 OK:

```json
{
  "id": 42,
  "type": "zip",
  "state": "running",
  "user_email": "you@example.com",
  "output_name": "photos.zip",
  "output_size_bytes": null,
  "progress_current": 1,
  "progress_total": 2,
  "error_message": null,
  "created_at": "2026-11-03 21:42:10.123456-06",
  "started_at": "2026-11-03 21:42:15.502114-06",
  "completed_at": null
}
```

### Download a job's file

`GET https://api.mailmasker.org/api/v1/jobs/:id/download`

The file a finished job made, such as its zip.

- Who can use it: Any signed-in account
- Area: Background jobs
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/job-download

Sends the file itself, named as the job's `output_name`. It is there once the job is completed, until the job is cleared away. A site admin can download anyone's.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The job's `id`. | `42` |

#### Good to know

- A job that hasn't finished yet answers 409.
- A job that isn't yours, or has been cleared away, answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/jobs/42/download' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/jobs/42/download',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli jobs download 42
```

#### Response

200 OK:

```text
(the job's output file)
```

## Services

Each fleet service (Drive, mail, domains, the activity log and the rest) records the address it can be reached at, and homelab-api forwards your calls there. The addresses are inside the fleet's own network; from outside, reach every service through this API.

### List the services

`GET https://api.mailmasker.org/api/v1/registry`

Every registered service, with its address, port and what it does.

- Who can use it: Any signed-in account
- Area: Services
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/list-services

Sorted by name. A service registered at more than one address appears once for each, the one heard from most recently first. `health_check_url` is the path at that address that says whether the service is up.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/registry' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/registry',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli registry list
```

#### Response

200 OK:

```json
[
  {
    "feature_name": "homelab-audit",
    "host": "192.0.2.10",
    "port": 2513,
    "health_check_url": "/health",
    "description": "Audit trail: drains api.audit_queue, serves /api/v1/audit/*"
  },
  {
    "feature_name": "homelab-drive",
    "host": "192.0.2.11",
    "port": 2501,
    "health_check_url": "/health",
    "description": "File storage web app + JSON API, serves /api/v1/drive/*"
  }
]
```

### Find a service

`GET https://api.mailmasker.org/api/v1/registry/:feature`

The address homelab-api sends one service's calls to.

- Who can use it: Any signed-in account
- Area: Services
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/find-service

When a service is registered at more than one address, this is the one heard from most recently, which is the one homelab-api forwards your calls to.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `feature` | path | yes | The service's name, as the list shows it. | `homelab-drive` |

#### Good to know

- A name no service has registered answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/registry/homelab-drive' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/registry/homelab-drive',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli registry lookup homelab-drive
```

#### Response

200 OK:

```json
{
  "feature_name": "homelab-drive",
  "host": "192.0.2.11",
  "port": 2501,
  "health_check_url": "/health",
  "description": "File storage web app + JSON API, serves /api/v1/drive/*"
}
```

## Users and roles

For site admins: the accounts on the system, suspending and restoring them, how much mail and drive space each may use, and what each may do. An account may do what its roles allow; a role carries a set of permissions, and a site admin may do everything.

### List accounts

`GET https://api.mailmasker.org/api/v1/admin/users`

Every account with its roles and whether it can sign in, or a quick search by address.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-list-users

Without a search, every account comes back, oldest first. `active` is 1 for an account that can sign in and 0 for a suspended one. The list includes the system's own non-person accounts too: one for each node's agent and one the api uses itself, all with the `system_agent` role.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `q` | query | no | Only accounts whose address starts with this text, ignoring case. At least 3 characters. |  |
| `limit` | query | no | With q, the most accounts to return, from 1 to 100. The default is 20. |  |

#### Good to know

- A search comes back in address order. One shorter than 3 characters answers an empty list.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/admin/users' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/admin/users',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin users list
```

#### Response

200 OK:

```json
[
  {
    "id": 1,
    "email": "you@example.com",
    "active": 1,
    "created_at": "2026-10-12 14:03:51.402117-05",
    "roles": ["user", "site_admin"]
  },
  {
    "id": 7,
    "email": "alice@example.com",
    "active": 1,
    "created_at": "2026-10-20 09:15:02.551873-05",
    "roles": ["user", "auditor"]
  },
  {
    "id": 9,
    "email": "bob@example.com",
    "active": 0,
    "created_at": "2026-10-28 18:40:37.004512-05",
    "roles": ["user"]
  }
]
```

### Create an account without an invite

`POST https://api.mailmasker.org/api/v1/admin/users/service-account`

Make an account directly, for a system mailbox or an operator or test login.

- Who can use it: Site admins
- Area: Users and roles
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/admin-create-service-account

For an account the invite process won't make: a system mailbox (the one invites are sent from, say), or an operator or test account on one of this system's own mail domains. It gets the ordinary `user` role and signs in to mail like any other account. Without a `password`, a random one is made. Either way the answer shows it, this once only.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `email` | body | yes | The new account's address. | `invites@example.com` |
| `password` | body | no | A password to use instead of a random one. At least 8 characters. |  |

#### Good to know

- Keep the password when you get it; it is never shown again.
- An address that already has an account answers 409, and a password shorter than 8 characters 400.
- The new account is recorded in the audit log.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/users/service-account' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"email":"invites@example.com"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/admin/users/service-account',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { email => 'invites@example.com' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin users create-service-account --email invites@example.com
```

#### Response

201 Created:

```json
{ "id": 12, "email": "invites@example.com", "password": "<generated password>" }
```

### Suspend or restore an account

`POST https://api.mailmasker.org/api/v1/admin/users/:id/active`

Stop an account signing in anywhere, or let it sign in again.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-suspend-user

Suspending stops every way the account signs in, all at once: the web, single sign-on, reading mail and sending it. It also ends the account's current sign-in sessions and their refresh tokens, so it can't go on using one it already has. Restoring lets it sign in again. Nothing is deleted either way.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The account's number, from the account list. | `7` |
| `active` | body | yes | false to suspend the account, true to restore it. | `false` |

#### Good to know

- Leaving out `active` counts as false, so it suspends.
- You can't suspend your own account; the answer is 400.
- An unknown account answers 404.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/users/7/active' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"active":false}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/admin/users/7/active',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { active => \0 })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true, "id": 7, "email": "alice@example.com", "active": false }
```

### See an account's mail usage

`GET https://api.mailmasker.org/api/v1/admin/mail-usage`

How much mail one account keeps, and the limit that applies to it.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-mail-usage

The bytes and messages in the account's mailbox, and its limit: its own if one is set, otherwise the 1 TB default (1099511627776 bytes). The mail server sends fresh figures each time the mailbox changes, so an account whose mailbox hasn't changed since that began shows null for both until it next does.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `user_email` | query | yes | The account's address, exactly as the account has it. | `alice@example.com` |

#### Good to know

- An address with no account still answers, with null figures and the default limit.
- An address without an @ answers 400.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/admin/mail-usage?user_email=alice%40example.com' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/admin/mail-usage',
    { Authorization => "Bearer $ENV{TOKEN}" },
    form => { user_email => 'alice@example.com' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "user_email": "alice@example.com",
  "used_bytes": 734003200,
  "messages": 4182,
  "limit_bytes": 5368709120
}
```

### Set an account's mail quota

`POST https://api.mailmasker.org/api/v1/admin/users/:id/mail-quota`

Change how much mail one account may keep, or put it back to the default.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-set-mail-quota

Sets the account's own limit, in bytes, which the mail server then enforces. Leave out `limit_bytes` (or send null) to remove the account's own limit, so the 1 TB default applies again.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The account's number, from the account list. | `7` |
| `limit_bytes` | body | no | The new limit in bytes, as a whole number. Leave it out, or send null, for the default. | `5368709120` |

#### Good to know

- Putting it back to the default answers a null `mail_quota_bytes` and a `note` saying "reset to default".
- A limit lower than what the account already keeps deletes nothing, so check its usage first.
- A limit that isn't a whole number answers 400, and an unknown account 404.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/users/7/mail-quota' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"limit_bytes":5368709120}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/admin/users/7/mail-quota',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { limit_bytes => 5368709120 })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true, "email": "alice@example.com", "mail_quota_bytes": 5368709120 }
```

### See an account's drive usage

`GET https://api.mailmasker.org/api/v1/drive/admin/usage`

How much one account keeps in drive, and the limit that applies to it.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-drive-usage

The bytes the account keeps in drive, not counting its trash, and its limit: its own if one is set, otherwise the 1 TB default (1099511627776 bytes).

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `user_email` | query | yes | The account's address, exactly as the account has it. | `alice@example.com` |

#### Good to know

- An address with no account answers 0 bytes used and the default limit.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/drive/admin/usage?user_email=alice%40example.com' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/drive/admin/usage',
    { Authorization => "Bearer $ENV{TOKEN}" },
    form => { user_email => 'alice@example.com' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "user_email": "alice@example.com", "used_bytes": 2147483648, "limit_bytes": 53687091200 }
```

### Set an account's drive quota

`PUT https://api.mailmasker.org/api/v1/drive/admin/quota`

Change how much one account may keep in drive, or put it back to the default.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-set-drive-quota

Sets the account's own limit, in bytes. An upload, or joining files together, that would take the account over it is refused; files in the trash don't count. Leave out `limit_bytes` (or send null) to remove the account's own limit, so the 1 TB default applies again.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `user_email` | body | yes | The account's address, exactly as the account has it. | `alice@example.com` |
| `limit_bytes` | body | no | The new limit in bytes, as a whole number. Leave it out, or send null, for the default. | `53687091200` |

#### Good to know

- Putting it back to the default answers a null `limit_bytes` and a `note` saying "reset to default".
- A limit lower than what the account already keeps deletes nothing; it only stops further uploads.
- It doesn't check that the address has an account.
- A limit that isn't a whole number answers 400.

#### Examples

cURL:

```bash
curl -s -X PUT 'https://api.mailmasker.org/api/v1/drive/admin/quota' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"user_email":"alice@example.com","limit_bytes":53687091200}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->put('https://api.mailmasker.org/api/v1/drive/admin/quota',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { user_email => 'alice@example.com', limit_bytes => 53687091200 })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true, "user_email": "alice@example.com", "limit_bytes": 53687091200 }
```

### List roles

`GET https://api.mailmasker.org/api/v1/admin/roles`

Every role, whether it can be deleted, and the permissions it carries.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-list-roles

In the order they were made. `protected` is 1 for the three built-in roles that can't be deleted, and 0 for the rest: `user`, `site_admin`, and `system_agent`, the role the fleet's own services sign in with. `site_admin` carries no permissions because it needs none: a site admin may always do everything.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/admin/roles' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/admin/roles',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin roles list
```

#### Response

200 OK:

```json
[
  {
    "id": 1,
    "name": "user",
    "description": "Default role -- standard API access",
    "protected": 1,
    "created_at": "2026-10-12 13:58:20.118347-05",
    "permissions": []
  },
  {
    "id": 2,
    "name": "site_admin",
    "description": "Administrative capabilities (informational only; admin routes are hardcoded, not gated by a table, so a bad edit can't lock every admin out)",
    "protected": 1,
    "created_at": "2026-10-12 13:58:20.118347-05",
    "permissions": []
  },
  {
    "id": 4,
    "name": "auditor",
    "description": "Reads the audit log",
    "protected": 0,
    "created_at": "2026-11-03 21:42:10.123456-06",
    "permissions": ["audit.view"]
  }
]
```

### Give an account a role

`POST https://api.mailmasker.org/api/v1/admin/users/:id/roles`

Add a role to an account, and with it everything the role allows.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-grant-role

The account gets every permission the role carries. Giving `site_admin` makes it a full site admin. Giving a role the account already has changes nothing. Each grant is recorded in the audit log.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The account's number, from the account list. | `7` |
| `role` | body | yes | The role's name, as the role list shows it. | `auditor` |

#### Good to know

- An unknown account answers 404, and an unknown role 400.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/users/7/roles' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"role":"auditor"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/admin/users/7/roles',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { role => 'auditor' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin users grant-role 7 auditor
```

#### Response

200 OK:

```json
{ "ok": true }
```

### Take a role from an account

`DELETE https://api.mailmasker.org/api/v1/admin/users/:id/roles/:role`

Remove one role from an account.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-revoke-role

The account loses what the role allowed, unless another of its roles allows the same. Each removal is recorded in the audit log.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The account's number, from the account list. | `7` |
| `role` | path | yes | The role's name. | `auditor` |

#### Good to know

- It answers ok even when the account didn't have the role.
- Nothing stops you taking `site_admin` from your own account, so take care.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/admin/users/7/roles/auditor' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/admin/users/7/roles/auditor',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin users revoke-role 7 auditor
```

#### Response

200 OK:

```json
{ "ok": true }
```

### Create a role

`POST https://api.mailmasker.org/api/v1/admin/roles`

Add a new role, with no permissions yet.

- Who can use it: Site admins
- Area: Users and roles
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/admin-create-role

A new role carries nothing until you give it permissions; then give the role to accounts. New roles are never protected, so they can be deleted later.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `name` | body | yes | The role's name. | `auditor` |
| `description` | body | no | What the role is for, in a few words. | `Reads the audit log` |

#### Good to know

- A name already in use answers 409.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/roles' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"auditor","description":"Reads the audit log"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/admin/roles',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { name => 'auditor', description => 'Reads the audit log' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin roles add auditor --description "Reads the audit log"
```

#### Response

201 Created:

```json
{
  "id": 4,
  "name": "auditor",
  "description": "Reads the audit log",
  "protected": 0,
  "created_at": "2026-11-03 21:42:10.123456-06",
  "permissions": []
}
```

### Delete a role

`DELETE https://api.mailmasker.org/api/v1/admin/roles/:name`

Remove a role, taking it from every account that has it.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-delete-role

The role goes completely: every account that had it loses it at once, along with the permissions it carried.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `name` | path | yes | The role's name. | `auditor` |

#### Good to know

- The built-in `user`, `site_admin` and `system_agent` roles are protected; deleting one answers 400.
- An unknown role answers 404.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/admin/roles/auditor' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/admin/roles/auditor',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin roles remove auditor
```

#### Response

200 OK:

```json
{ "ok": true }
```

### List permissions

`GET https://api.mailmasker.org/api/v1/admin/permissions`

Every permission a role can be given, alphabetically.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-list-permissions

Permissions come with the software, since each one only means something where the code checks for it, so there is no way to add one here. Give them to roles; a site admin holds them all already.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/admin/permissions' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/admin/permissions',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin permissions list
```

#### Response

200 OK:

```json
[
  {
    "id": 1,
    "name": "audit.view",
    "description": "View the system audit log (own entries always allowed; this grants viewing other users' entries too)"
  },
  {
    "id": 3,
    "name": "domains.catchall",
    "description": "Manage domain catch-all routing + domain send-as (the myaccount \"Domain catch-all routing\" area)"
  },
  {
    "id": 2,
    "name": "metrics.view",
    "description": "View the fleet metrics dashboard (Prometheus-backed) in myaccount"
  }
]
```

### Give a role a permission

`POST https://api.mailmasker.org/api/v1/admin/roles/:role/permissions/:permission`

Let every account with a role do one more thing.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-grant-permission

Every account with the role gets the permission. Giving a role a permission it already has changes nothing.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `role` | path | yes | The role's name. | `auditor` |
| `permission` | path | yes | The permission's name, from the permission list. | `audit.view` |

#### Good to know

- An unknown role or permission answers 404.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/roles/auditor/permissions/audit.view' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/admin/roles/auditor/permissions/audit.view',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin roles grant-permission auditor audit.view
```

#### Response

200 OK:

```json
{ "ok": true }
```

### Take a permission from a role

`DELETE https://api.mailmasker.org/api/v1/admin/roles/:role/permissions/:permission`

Stop a role carrying one permission.

- Who can use it: Site admins
- Area: Users and roles
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-revoke-permission

Every account with the role loses the permission, unless another of its roles carries it too.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `role` | path | yes | The role's name. | `auditor` |
| `permission` | path | yes | The permission's name. | `audit.view` |

#### Good to know

- It answers ok even when the role didn't have the permission, or either name is unknown.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/admin/roles/auditor/permissions/audit.view' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/admin/roles/auditor/permissions/audit.view',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin roles revoke-permission auditor audit.view
```

#### Response

200 OK:

```json
{ "ok": true }
```

## Domains and DNS

For site admins: the domains this system serves and whether each takes mail, their DNS records, the extra addresses (mail aliases) routed to each account, every account's block and allow entries, and each domain's default for inbox block links.

### List domains

`GET https://api.mailmasker.org/api/v1/domains`

Every domain on the system, alphabetically, and whether it takes mail.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/domains-list

Mail to a domain is accepted only while both `mail_enabled` and `active` are on. `dns_managed` means its DNS zone is kept here.

#### Good to know

- Yes-or-no fields come back as 1 or 0.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/domains' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/domains',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns domains list
```

#### Response

200 OK:

```json
[
  {
    "id": 1,
    "domain_name": "example.com",
    "mail_enabled": 1,
    "dns_managed": 1,
    "active": 1,
    "created_by": "you@example.com",
    "created_at": "2026-11-03 21:42:10.123456-06",
    "updated_at": "2026-11-03 21:42:10.123456-06"
  },
  {
    "id": 2,
    "domain_name": "example.org",
    "mail_enabled": 0,
    "dns_managed": 1,
    "active": 1,
    "created_by": "you@example.com",
    "created_at": "2026-11-03 21:42:10.123456-06",
    "updated_at": "2026-11-03 21:42:10.123456-06"
  }
]
```

### Add a domain

`POST https://api.mailmasker.org/api/v1/domains`

Start serving a domain, for mail, DNS or both.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/domain-add

Unless you say otherwise, mail for the domain is accepted from now on and it gets a DNS zone here. A zone that already exists is kept as it is.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain_name` | body | yes | The domain, in lowercase. | `example.com` |
| `mail_enabled` | body | no | Accept mail for it. true if left out; false for DNS only. |  |
| `dns_managed` | body | no | Keep its DNS zone here, creating the zone if needed. true if left out; false for mail only. |  |
| `nameservers` | body | no | The name servers for a new zone, as a list. If left out, the zone's name server is `ns-test.` followed by the domain. |  |

#### Good to know

- A domain that is already listed answers 409.
- If the DNS zone can't be created, the answer is 502 and the domain isn't added.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/domains' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"domain_name":"example.com"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/domains',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { domain_name => 'example.com' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns domains add example.com
```

#### Response

201 Created:

```json
{
  "id": 3,
  "domain_name": "example.com",
  "mail_enabled": 1,
  "dns_managed": 1,
  "active": 1,
  "created_by": "you@example.com",
  "created_at": "2026-11-03 21:42:10.123456-06",
  "updated_at": "2026-11-03 21:42:10.123456-06"
}
```

### See a domain's settings

`GET https://api.mailmasker.org/api/v1/domains/:domain`

One domain, with the same fields as the list of domains.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/domain-show

Whether the domain takes mail (`mail_enabled` and `active` both on), whether its DNS zone is kept here, and who added it when.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain name. | `example.com` |

#### Good to know

- A domain that isn't listed answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/domains/example.com' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/domains/example.com',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns domains show example.com
```

#### Response

200 OK:

```json
{
  "id": 1,
  "domain_name": "example.com",
  "mail_enabled": 1,
  "dns_managed": 1,
  "active": 1,
  "created_by": "you@example.com",
  "created_at": "2026-11-03 21:42:10.123456-06",
  "updated_at": "2026-11-03 21:42:10.123456-06"
}
```

### Change a domain's settings

`PATCH https://api.mailmasker.org/api/v1/domains/:domain`

Turn a domain's mail, its DNS management, or the whole domain on or off.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/domain-change

Send only what you want to change; the rest stays as it is. The answer is the domain's settings afterwards.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain name. | `example.com` |
| `mail_enabled` | body | no | Accept mail for the domain. | `true` |
| `dns_managed` | body | no | Whether its DNS zone is kept here. Only the setting changes; no zone is created or removed. |  |
| `active` | body | no | false turns the whole domain off, as Disable a domain does; true turns it back on. |  |

#### Good to know

- A domain that isn't listed answers 404.

#### Examples

cURL:

```bash
curl -s -X PATCH 'https://api.mailmasker.org/api/v1/domains/example.com' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"mail_enabled":true}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->patch('https://api.mailmasker.org/api/v1/domains/example.com',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { mail_enabled => \1 })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns domains enable example.com
```

#### Response

200 OK:

```json
{
  "id": 1,
  "domain_name": "example.com",
  "mail_enabled": 1,
  "dns_managed": 1,
  "active": 1,
  "created_by": "you@example.com",
  "created_at": "2026-11-03 21:42:10.123456-06",
  "updated_at": "2026-11-03 21:50:02.654321-06"
}
```

### Disable a domain

`DELETE https://api.mailmasker.org/api/v1/domains/:domain`

Stop accepting mail for a domain, without deleting anything.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/domain-disable

Turns the domain off (`active` becomes 0), so mail to it is refused from now on. Nothing is deleted: its DNS zone is still served and its extra addresses stay. Change a domain's settings with `active` true turns it back on.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain name. | `example.com` |

#### Good to know

- A domain that isn't listed answers 404.
- homelab-cli's `dns domains disable` turns `mail_enabled` off instead, through Change a domain's settings. Mail to the domain is refused either way.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/example.com' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/example.com',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true }
```

### List a domain's DNS records

`GET https://api.mailmasker.org/api/v1/domains/:domain/dns/records`

Every record in a domain's DNS zone.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/dns-records-list

One item per name and type, with all of its values in `content`. Names are full names ending in a dot, and TXT values keep their quotes.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain name. | `example.com` |

#### Good to know

- A domain that isn't listed, or has no DNS zone here, answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/domains/example.com/dns/records' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/domains/example.com/dns/records',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns records list example.com
```

#### Response

200 OK:

```json
[
  { "name": "example.com.", "type": "MX", "ttl": 3600, "content": ["10 mail.example.com."] },
  { "name": "example.com.", "type": "TXT", "ttl": 3600, "content": ["\"v=spf1 mx ~all\""] },
  { "name": "www.example.com.", "type": "A", "ttl": 3600, "content": ["203.0.113.10"] }
]
```

### Create or replace a DNS record

`POST https://api.mailmasker.org/api/v1/domains/:domain/dns/records`

Set every value of one name and type in a domain's DNS zone.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/dns-record-set

What you send replaces what that name and type held before, so to add a second value (a backup MX, say) send both. TXT values are put in quotes for you, and the host name at the end of an MX, NS, CNAME, PTR or SRV value gets its final dot. `restart_pending` is true when the name and type had no record before.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain whose zone it goes in. | `example.com` |
| `name` | body | yes | The record's full name, such as `www.example.com`, or the domain itself for its own records. A final dot is optional. | `www.example.com` |
| `type` | body | yes | The record type in capitals, such as A, AAAA, CNAME, MX or TXT. | `A` |
| `content` | body | yes | One value, or a list of them. | `["203.0.113.10"]` |
| `ttl` | body | no | How long, in seconds, others may keep it. 3600 if left out. | `3600` |

#### Good to know

- A domain that isn't listed answers 404.
- A record the DNS server refuses (a name outside the domain, say) answers 502, with its reason.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/example.com/dns/records' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"www.example.com","type":"A","content":["203.0.113.10"],"ttl":3600}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/domains/example.com/dns/records',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { name => 'www.example.com', type => 'A', content => ['203.0.113.10'], ttl => 3600 })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns records add example.com --name www.example.com --type A --value 203.0.113.10
```

#### Response

201 Created:

```json
{ "ok": true, "restart_pending": true }
```

### Delete a DNS record

`DELETE https://api.mailmasker.org/api/v1/domains/:domain/dns/records`

Remove every value of one name and type from a domain's DNS zone.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/dns-record-delete

Deletes the whole record set for that name and type. They go in the body, as when creating a record.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain whose zone it is in. | `example.com` |
| `name` | body | yes | The record's full name. A final dot is optional. | `www.example.com` |
| `type` | body | yes | The record type in capitals. | `A` |

#### Good to know

- A domain that isn't listed answers 404.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/example.com/dns/records' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"www.example.com","type":"A"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/example.com/dns/records',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { name => 'www.example.com', type => 'A' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns records delete example.com --name www.example.com --type A
```

#### Response

200 OK:

```json
{ "ok": true, "restart_pending": true }
```

### List extra addresses

`GET https://api.mailmasker.org/api/v1/domains/mail-aliases`

Every extra address and whole domain routed to an account, and whether it may send from them.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/aliases-list

`source_pattern` is an exact address, or `@` and a domain for all of that domain's mail; domain catch-alls are listed here too. `destination` is the account it goes to, `active` whether its mail is delivered, and `send_enabled` whether that account may also send from it.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `destination` | query | no | Only what is routed to this account. |  |

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/domains/mail-aliases' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/domains/mail-aliases',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns mail-aliases list
```

#### Response

200 OK:

```json
[
  {
    "id": 4,
    "source_pattern": "@example.org",
    "destination": "you@example.com",
    "active": 1,
    "send_enabled": 0,
    "created_by": "you@example.com",
    "created_at": "2026-11-03 21:42:10.123456-06",
    "updated_at": "2026-11-03 21:42:10.123456-06"
  },
  {
    "id": 5,
    "source_pattern": "sales@example.com",
    "destination": "you@example.com",
    "active": 1,
    "send_enabled": 1,
    "created_by": "you@example.com",
    "created_at": "2026-11-03 21:42:10.123456-06",
    "updated_at": "2026-11-03 21:42:10.123456-06"
  }
]
```

### Give an account an extra address

`POST https://api.mailmasker.org/api/v1/domains/mail-aliases`

Route an address, or a whole domain, to an account, which may also send from it.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/alias-add

Mail to the address goes to the account, which may also send from it unless `send_enabled` is false. For a whole domain, that is every address there without a mailbox or entry of its own. Giving an address that is already routed moves it to the new account.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `source_pattern` | body | yes | An exact address, or `@` and a domain for the whole domain. | `sales@example.com` |
| `destination` | body | yes | The account it goes to. It isn't checked, so make sure it is a real one. | `you@example.com` |
| `send_enabled` | body | no | Whether the account may send from it. true if left out. |  |

#### Good to know

- A domain that isn't listed yet is added, with a DNS zone but with mail off. The account can send from it, but mail sent to it arrives only once mail is turned on (Change a domain's settings).
- If that DNS zone can't be created, the answer is 502 and nothing is added.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/mail-aliases' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"source_pattern":"sales@example.com","destination":"you@example.com"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/domains/mail-aliases',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { source_pattern => 'sales@example.com', destination => 'you@example.com' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns mail-aliases add sales@example.com you@example.com
```

#### Response

201 Created:

```json
{
  "id": 5,
  "source_pattern": "sales@example.com",
  "destination": "you@example.com",
  "active": 1,
  "send_enabled": 1,
  "created_by": "you@example.com",
  "created_at": "2026-11-03 21:42:10.123456-06",
  "updated_at": "2026-11-03 21:42:10.123456-06"
}
```

### Turn sending on or off for an extra address

`PATCH https://api.mailmasker.org/api/v1/domains/mail-aliases/:source_pattern`

Let an account send from an extra address, or stop it, while its mail keeps arriving.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/alias-sending

Only sending changes: mail to the address reaches the account either way, so this can suspend sending without breaking delivery.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `source_pattern` | path | yes | The exact address, or `@` and a domain, as the list shows it. The `@` may be sent as it is or as `%40`. | `sales@example.com` |
| `send_enabled` | body | yes | true to allow sending from it, false to stop it. | `false` |

#### Good to know

- An address that isn't on the list answers 404.

#### Examples

cURL:

```bash
curl -s -X PATCH 'https://api.mailmasker.org/api/v1/domains/mail-aliases/sales%40example.com' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"send_enabled":false}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->patch('https://api.mailmasker.org/api/v1/domains/mail-aliases/sales%40example.com',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { send_enabled => \0 })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns mail-aliases disable-send sales@example.com
```

#### Response

200 OK:

```json
{
  "id": 5,
  "source_pattern": "sales@example.com",
  "destination": "you@example.com",
  "active": 1,
  "send_enabled": 0,
  "created_by": "you@example.com",
  "created_at": "2026-11-03 21:42:10.123456-06",
  "updated_at": "2026-11-03 21:50:02.654321-06"
}
```

### Remove an extra address

`DELETE https://api.mailmasker.org/api/v1/domains/mail-aliases/:source_pattern`

Stop routing an address or domain to an account, and stop the account sending from it.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/alias-remove

Removes the entry completely: its mail no longer reaches the account, and a send from it is refused. To stop only sending, turn sending off instead. The domain itself stays.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `source_pattern` | path | yes | The exact address, or `@` and a domain, as the list shows it. The `@` may be sent as it is or as `%40`. | `sales@example.com` |

#### Good to know

- An address that isn't on the list answers 404.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/mail-aliases/sales%40example.com' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/mail-aliases/sales%40example.com',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns mail-aliases remove sales@example.com
```

#### Response

200 OK:

```json
{ "ok": true }
```

### List every block and allow entry

`GET https://api.mailmasker.org/api/v1/domains/recipient-access`

The blocked and allowed addresses of every account and site admin, alphabetically.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/all-blocks-list

Entries a site admin made have a null `user_email`; the rest belong to the account in `user_email`, which made them for one of its own addresses. An `action` of `OK` is an allow; any other action is a block.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `user` | query | no | Only the entries this account made for itself. |  |

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/domains/recipient-access' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/domains/recipient-access',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns recipient-access list
```

#### Response

200 OK:

```json
[
  {
    "id": 7,
    "recipient": "news@example.org",
    "action": "REJECT",
    "reason": "Spam",
    "created_by": "you@example.com",
    "user_email": null,
    "created_at": "2026-11-03 21:42:10.123456-06",
    "updated_at": "2026-11-03 21:42:10.123456-06"
  },
  {
    "id": 8,
    "recipient": "shop@example.com",
    "action": "REJECT",
    "reason": "blocked via inbox link",
    "created_by": "pat@example.com",
    "user_email": "pat@example.com",
    "created_at": "2026-11-03 21:42:10.123456-06",
    "updated_at": "2026-11-03 21:42:10.123456-06"
  }
]
```

### Block or allow any address

`POST https://api.mailmasker.org/api/v1/domains/recipient-access`

Reject all mail to an address, or let it through ahead of every other check.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/block-any-address

Takes effect at once, for mail from every sender. Unlike blocking your own addresses, nothing here checks whose address it is.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `recipient` | body | yes | The address. Any address, not only one of yours. | `shop@example.com` |
| `action` | body | yes | REJECT to block, or OK to allow ahead of every other check. Other Postfix actions, such as DISCARD, are passed on as written. | `REJECT` |
| `reason` | body | no | Why. A blocked sender sees it in the rejection. | `Sells addresses` |

#### Good to know

- An address already on the list is updated in place, and stays with the account or admin who first made it.
- Nothing stops this blocking an account's own login address, which cuts off all of its mail.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/recipient-access' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"recipient":"shop@example.com","action":"REJECT","reason":"Sells addresses"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/domains/recipient-access',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { recipient => 'shop@example.com', action => 'REJECT', reason => 'Sells addresses' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns recipient-access block shop@example.com --reason "Sells addresses"
```

#### Response

201 Created:

```json
{
  "id": 9,
  "recipient": "shop@example.com",
  "action": "REJECT",
  "reason": "Sells addresses",
  "created_by": "you@example.com",
  "user_email": null,
  "created_at": "2026-11-03 21:42:10.123456-06",
  "updated_at": "2026-11-03 21:42:10.123456-06"
}
```

### Remove a block or allow entry

`DELETE https://api.mailmasker.org/api/v1/domains/recipient-access/:recipient`

Take any address off the block-and-allow list, whoever put it there.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/unblock-any-address

Removes the entry for this address, whether a site admin or the account itself made it, so its mail is handled as usual again.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `recipient` | path | yes | The address, exactly as the list shows it. | `shop@example.com` |

#### Good to know

- An address that isn't on the list answers 404.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/recipient-access/shop%40example.com' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/recipient-access/shop%40example.com',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns recipient-access remove shop@example.com
```

#### Response

200 OK:

```json
{ "ok": true }
```

### See a domain's inbox-link default

`GET https://api.mailmasker.org/api/v1/block-link/domains/:domain`

Whether accounts on a domain get a "block this address" link by default, and where it goes.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/domain-block-link-show

The setting covers accounts whose login address is at this domain. `enabled` is the default each of them follows until it makes its own choice. `mode` is where the link goes, for all of them: `header` (a List-Unsubscribe header), `body` (a line at the end of the message) or `both`.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain name. | `example.com` |

#### Good to know

- A domain that has never been set answers `enabled` false and `mode` header, with no `updated_at`.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/block-link/domains/example.com' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/block-link/domains/example.com',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin block-link domain show example.com
```

#### Response

200 OK:

```json
{
  "domain_name": "example.com",
  "enabled": 1,
  "mode": "both",
  "updated_by_email": "you@example.com",
  "updated_at": "2026-11-03 21:42:10.123456-06"
}
```

### Set a domain's inbox-link default

`PUT https://api.mailmasker.org/api/v1/block-link/domains/:domain`

Choose whether accounts on a domain get a "block this address" link, and where it goes.

- Who can use it: Site admins
- Area: Domains and DNS
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/domain-block-link-set

Accounts whose login address is at this domain follow `enabled` unless they have made their own choice; `mode` applies to all of them.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain name. | `example.com` |
| `enabled` | body | yes | true to add the link by default, false for none. | `true` |
| `mode` | body | yes | Where the link goes, `header`, `body` or `both`. | `both` |

#### Examples

cURL:

```bash
curl -s -X PUT 'https://api.mailmasker.org/api/v1/block-link/domains/example.com' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"enabled":true,"mode":"both"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->put('https://api.mailmasker.org/api/v1/block-link/domains/example.com',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { enabled => \1, mode => 'both' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin block-link domain set example.com --enabled --mode both
```

#### Response

200 OK:

```json
{
  "domain_name": "example.com",
  "enabled": 1,
  "mode": "both",
  "updated_by_email": "you@example.com",
  "updated_at": "2026-11-03 21:42:10.123456-06"
}
```

## Catch-all routing

Send a domain's mail to one mailbox. Mail to an address with its own mailbox or extra-address entry still goes there; everything else at the domain goes to the catch-all.

### List every domain's catch-all

`GET https://api.mailmasker.org/api/v1/domains/catch-alls`

Every domain on the system, alphabetically, with where its catch-all sends mail.

- Who can use it: Site admins, and accounts granted the domains.catchall permission
- Area: Catch-all routing
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/catch-alls-list

`destination` is the catch-all's mailbox, or null when the domain has none. The domain takes mail while both `mail_enabled` and `active` are on. `send_enabled` is whether the catch-all's mailbox may also send from any address at the domain, and `has_dkim` whether the domain signs the mail it sends.

#### Good to know

- Yes-or-no fields come back as 1 or 0, and the catch-all's own fields as null when there is none.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/domains/catch-alls' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/domains/catch-alls',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
[
  {
    "domain": "example.com",
    "mail_enabled": 1,
    "active": 1,
    "destination": null,
    "catchall_active": null,
    "send_enabled": null,
    "has_dkim": 1
  },
  {
    "domain": "example.org",
    "mail_enabled": 1,
    "active": 1,
    "destination": "you@example.com",
    "catchall_active": 1,
    "send_enabled": 0,
    "has_dkim": 0
  }
]
```

### See a domain's catch-all

`GET https://api.mailmasker.org/api/v1/domains/:domain/catch-all`

Where one domain's catch-all sends mail, if anywhere.

- Who can use it: Site admins, and accounts granted the domains.catchall permission
- Area: Catch-all routing
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/catch-all-show

`destination` is the catch-all's mailbox, or null for none. A domain the system doesn't serve yet isn't an error: it answers `managed` false, and setting a catch-all for it adds it.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain name. | `example.org` |

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/domains/example.org/catch-all' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/domains/example.org/catch-all',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "domain": "example.org",
  "managed": true,
  "mail_enabled": 1,
  "active": 1,
  "destination": "you@example.com",
  "catchall_active": 1
}
```

### Set a domain's catch-all

`PUT https://api.mailmasker.org/api/v1/domains/:domain/catch-all`

Send all mail for a domain to one mailbox.

- Who can use it: Site admins, and accounts granted the domains.catchall permission
- Area: Catch-all routing
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/catch-all-set

Sets or replaces the domain's catch-all, and turns mail on for the domain, even one that was disabled. A domain the system doesn't serve yet is added, but gets no DNS zone here, so its mail must already be pointed at this system.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain whose mail to catch. | `example.org` |
| `destination` | body | yes | The mailbox it goes to. It must not be at the same domain. | `you@example.com` |
| `send_enabled` | body | no | Whether that mailbox may also send from any address at the domain. Left out, it stays as it was (off for a new catch-all). |  |

#### Good to know

- The destination isn't checked here, so check it first with Check that a mailbox exists, as myaccount does.
- A destination at the same domain answers 400, and one that is itself an extra address answers 409, since either would loop.

#### Examples

cURL:

```bash
curl -s -X PUT 'https://api.mailmasker.org/api/v1/domains/example.org/catch-all' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"destination":"you@example.com"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->put('https://api.mailmasker.org/api/v1/domains/example.org/catch-all',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { destination => 'you@example.com' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "ok": true,
  "domain": "example.org",
  "destination": "you@example.com",
  "mail_enabled": true,
  "send_enabled": false
}
```

### Remove a domain's catch-all

`DELETE https://api.mailmasker.org/api/v1/domains/:domain/catch-all`

Stop sending a domain's leftover mail to one mailbox.

- Who can use it: Site admins, and accounts granted the domains.catchall permission
- Area: Catch-all routing
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/catch-all-clear

Mail to addresses at the domain without a mailbox or entry of their own is refused again. The domain keeps taking mail for the rest.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain name. | `example.org` |

#### Good to know

- A domain with no catch-all answers 404.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/example.org/catch-all' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/example.org/catch-all',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true, "domain": "example.org" }
```

### Check that a mailbox exists

`GET https://api.mailmasker.org/api/v1/mail/recipient-exists`

Whether an address is an active account's mailbox.

- Who can use it: Site admins, and accounts granted the domains.catchall permission
- Area: Catch-all routing
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/mailbox-exists

Matches the whole address exactly, ignoring case, and never lists or suggests others. Use it before setting a catch-all, which doesn't check its destination.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `email` | query | yes | The full address to check. | `you@example.com` |

#### Good to know

- A suspended account, or text without an @, answers false.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/mail/recipient-exists?email=you%40example.com' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/mail/recipient-exists',
    { Authorization => "Bearer $ENV{TOKEN}" },
    form => { email => 'you@example.com' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "exists": true }
```

## DKIM signing keys

Each domain's outgoing mail is signed with a DKIM key, so receivers can tell it really came from us. Changing a key takes three steps: make a new one, wait for its DNS record to spread, then switch to it. The old key stays published for a while (7 days unless the site has set another time), then is removed on its own.

### List a domain's signing keys

`GET https://api.mailmasker.org/api/v1/domains/:domain/dkim/selectors`

Every DKIM key a domain has had, by selector, newest first, with its state.

- Who can use it: Site admins
- Area: DKIM signing keys
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/dkim-list

A key's `state` is pending (published in DNS, not signing yet), active (signing the domain's mail; there is at most one), retiring (replaced, but kept in DNS until `retire_after` so mail it already signed still checks out) or retired (gone from DNS).

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain. | `example.com` |

#### Good to know

- A domain that isn't on the system answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/domains/example.com/dkim/selectors' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/domains/example.com/dkim/selectors',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns dkim list example.com
```

#### Response

200 OK:

```json
[
  {
    "id": 9,
    "selector": "20261103a",
    "state": "pending",
    "key_bits": 2048,
    "created_at": "2026-11-03 21:42:10.123456-06",
    "activated_at": null,
    "retiring_at": null,
    "retire_after": null,
    "retired_at": null
  },
  {
    "id": 7,
    "selector": "20261020a",
    "state": "active",
    "key_bits": 2048,
    "created_at": "2026-10-20 10:02:44.318200-05",
    "activated_at": "2026-10-20 14:30:05.771032-05",
    "retiring_at": null,
    "retire_after": null,
    "retired_at": null
  }
]
```

### Make a new signing key

`POST https://api.mailmasker.org/api/v1/domains/:domain/dkim/rotate`

Create a new DKIM key for a domain and publish it in DNS, ready to switch to.

- Who can use it: Site admins
- Area: DKIM signing keys
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/dkim-rotate

Makes a new 2048-bit key, its selector named for today's date, such as `20261103a` (`20261103b` for a second one that day), and publishes its public half in DNS, where receivers look it up. It starts out pending and signs nothing until you switch to it.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain. | `example.com` |

#### Good to know

- If the key can't be made or its DNS record can't be published, the answer is 502 and nothing is left behind.
- The private half of a key is never shown.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/example.com/dkim/rotate' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/domains/example.com/dkim/rotate',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns dkim rotate example.com
```

#### Response

201 Created:

```json
{
  "id": 9,
  "domain_id": 3,
  "selector": "20261103a",
  "state": "pending",
  "public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...IDAQAB",
  "key_bits": 2048,
  "created_by": "you@example.com",
  "activated_by": null,
  "retired_by": null,
  "created_at": "2026-11-03 21:42:10.123456-06",
  "activated_at": null,
  "retiring_at": null,
  "retire_after": null,
  "retired_at": null,
  "next_action_at": null
}
```

### Switch to a new signing key

`POST https://api.mailmasker.org/api/v1/domains/:domain/dkim/:selector/activate`

Start signing a domain's mail with a pending key, and start retiring the old one.

- Who can use it: Site admins
- Area: DKIM signing keys
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/dkim-activate

The pending key becomes active and signs the domain's outgoing mail from now on. The key it replaces turns retiring: it stays in DNS until its `retire_after`, so mail it already signed still checks out, and is then removed on its own.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain. | `example.com` |
| `selector` | path | yes | The pending key's selector, from List a domain's signing keys. | `20261103a` |

#### Good to know

- Switch only once the new key's DNS record has had time to spread; until then, receivers can't check mail signed with it.
- Only a pending key can be switched to; any other answers 409.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/example.com/dkim/20261103a/activate' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/domains/example.com/dkim/20261103a/activate',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns dkim activate example.com 20261103a
```

#### Response

200 OK:

```json
{
  "id": 9,
  "domain_id": 3,
  "selector": "20261103a",
  "state": "active",
  "public_key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...IDAQAB",
  "key_bits": 2048,
  "created_by": "you@example.com",
  "activated_by": "you@example.com",
  "retired_by": null,
  "created_at": "2026-11-03 21:42:10.123456-06",
  "activated_at": "2026-11-04 09:15:27.640118-06",
  "retiring_at": null,
  "retire_after": null,
  "retired_at": null,
  "next_action_at": null
}
```

### Retire a signing key now

`POST https://api.mailmasker.org/api/v1/domains/:domain/dkim/:selector/retire`

Take an active or retiring key out of use and out of DNS straight away.

- Who can use it: Site admins
- Area: DKIM signing keys
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/dkim-retire

For a key you think has leaked. It skips the rest of the usual wait: the DNS record and the key go at once, so mail it signed that is still on its way fails its signature check. Normally keys retire on their own, after a newer one is switched to.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain. | `example.com` |
| `selector` | path | yes | The key's selector. | `20261020a` |

#### Good to know

- Retiring the active key leaves the domain's mail unsigned until you switch to another.
- A pending key can't be retired (cancel it instead), and neither can a retired one; both answer 409.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/example.com/dkim/20261020a/retire' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/domains/example.com/dkim/20261020a/retire',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli dns dkim retire example.com 20261020a
```

#### Response

200 OK:

```json
{ "ok": true }
```

### Cancel a new signing key

`DELETE https://api.mailmasker.org/api/v1/domains/:domain/dkim/:selector`

Throw away a pending key that was never switched to.

- Who can use it: Site admins
- Area: DKIM signing keys
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/dkim-cancel

Removes the key's DNS record and the key itself, and drops it from the list altogether, where a retired key would stay listed.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `domain` | path | yes | The domain. | `example.com` |
| `selector` | path | yes | The pending key's selector. | `20261103a` |

#### Good to know

- Only a pending key can be cancelled; any other answers 409, and retire is the way to take it out of use.

#### Examples

cURL:

```bash
curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/example.com/dkim/20261103a' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/example.com/dkim/20261103a',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{ "ok": true }
```

## DMARC reports

Mail systems that receive mail in our domains' names send back daily reports on it: how much passed DMARC, how much failed, and from which addresses it came. They are fetched from the reports mailbox once an hour; these calls only read what has been stored.

### Check the report fetching

`GET https://api.mailmasker.org/api/v1/dmarc/status`

When reports were last fetched, how that went, and how many are stored.

- Who can use it: Site admins
- Area: DMARC reports
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/dmarc-status

`ingest` is the hourly fetch's last run: when, on which host, and its note (what it found, or ERROR and why). `store` covers every report kept so far, with the earliest and latest dates they cover. Look here first, since a summary with nothing in it can also mean the fetching has stopped.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/dmarc/status' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/dmarc/status',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "ingest": {
    "last_run": "2026-11-03 21:00:04.512339-06",
    "last_host": "admin-node-1",
    "last_note": "seen=3 reports+=3 records+=7 skipped=0 failed=0"
  },
  "store": {
    "reports": 412,
    "last_ingest": "2026-11-03 21:00:03.998120-06",
    "earliest": "2026-11-01 18:00:00-06",
    "latest": "2026-11-03 17:59:59-06"
  }
}
```

### Summarize DMARC results by domain

`GET https://api.mailmasker.org/api/v1/dmarc/summary`

For each domain, how many messages passed DMARC and how many failed.

- Who can use it: Site admins
- Area: DMARC reports
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/dmarc-summary

One row per domain, the most failing mail first. Every count is of messages: `aligned` passed DMARC, `failing` did not, and `acted_on` were quarantined or rejected by the receiver. `policy_p` is the policy receivers found published, `reporters` how many receivers sent reports, and `sources` how many sending addresses they saw.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `days` | query | no | How many days back to look, by when each report's period began. 30 if you leave it out, 3650 at most. |  |

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/dmarc/summary' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/dmarc/summary',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "days": 30,
  "domains": [
    {
      "domain": "example.com",
      "policy_p": "quarantine",
      "reports": 58,
      "reporters": 4,
      "sources": 6,
      "messages": 1290,
      "aligned": 1281,
      "failing": 9,
      "acted_on": 9,
      "window_start": "2026-10-04 19:00:00-05",
      "window_end": "2026-11-03 17:59:59-06"
    }
  ]
}
```

### List who sends mail as our domains

`GET https://api.mailmasker.org/api/v1/dmarc/sources`

Each address the reports saw sending in a domain's name, and how much of its mail passed.

- Who can use it: Site admins
- Area: DMARC reports
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/dmarc-sources

One row per sending address and domain, the most failing mail first, 500 rows at most. An address that isn't one of ours, sending mail that fails, is someone else using the domain's name.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `days` | query | no | How many days back to look. 30 if you leave it out. |  |
| `domain` | query | no | Only this domain. |  |
| `failing` | query | no | 1 for only the addresses with some failing mail. |  |

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/dmarc/sources' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/dmarc/sources',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "days": 30,
  "domain": null,
  "sources": [
    {
      "source_ip": "203.0.113.77",
      "domain": "example.com",
      "messages": 9,
      "aligned": 0,
      "failing": 9,
      "reporters": 1,
      "last_seen": "2026-11-02 17:59:59-06"
    },
    {
      "source_ip": "192.0.2.25",
      "domain": "example.com",
      "messages": 1281,
      "aligned": 1281,
      "failing": 0,
      "reporters": 4,
      "last_seen": "2026-11-03 17:59:59-06"
    }
  ]
}
```

### List DMARC reports

`GET https://api.mailmasker.org/api/v1/dmarc/reports`

The reports received, newest first, with who sent each and the period it covers.

- Who can use it: Site admins
- Area: DMARC reports
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/dmarc-reports

One row per report. `org_name` is the receiver that sent it, `date_begin` to `date_end` the period it covers, and the `policy_` fields the policy it found published. `messages` is how many messages it covers, and `records` how many lines it has.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `days` | query | no | How many days back to look. 30 if you leave it out. |  |
| `domain` | query | no | Only reports about this domain. |  |
| `limit` | query | no | At most this many reports. 100 if you leave it out, 1000 at most. |  |

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/dmarc/reports' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/dmarc/reports',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "days": 30,
  "reports": [
    {
      "id": 412,
      "org_name": "example.net",
      "report_id": "18342296310421557",
      "policy_domain": "example.com",
      "policy_p": "quarantine",
      "policy_pct": 100,
      "policy_adkim": "r",
      "policy_aspf": "r",
      "date_begin": "2026-11-02 18:00:00-06",
      "date_end": "2026-11-03 17:59:59-06",
      "ingested_at": "2026-11-03 21:00:03.998120-06",
      "messages": 214,
      "records": 2
    }
  ]
}
```

### Read a DMARC report

`GET https://api.mailmasker.org/api/v1/dmarc/reports/:id`

One report in full, with a line for each sending address it covers.

- Who can use it: Site admins
- Area: DMARC reports
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/dmarc-report

Each of `records` is a number of messages from one address, and what the receiver made of them. `dkim_result` and `spf_result` say whether each lined up with the domain, which is what DMARC goes by. The checks themselves, with the DKIM key's `selector`, are in `auth_results`; a signature can pass there and still not line up.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `id` | path | yes | The report's `id`, from List DMARC reports. | `412` |

#### Good to know

- An id with no report answers 404.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/dmarc/reports/412' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/dmarc/reports/412',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "id": 412,
  "org_name": "example.net",
  "org_email": "dmarc-noreply@example.net",
  "report_id": "18342296310421557",
  "date_begin": "2026-11-02 18:00:00-06",
  "date_end": "2026-11-03 17:59:59-06",
  "policy_domain": "example.com",
  "policy_p": "quarantine",
  "policy_sp": null,
  "policy_pct": 100,
  "policy_adkim": "r",
  "policy_aspf": "r",
  "ingested_at": "2026-11-03 21:00:03.998120-06",
  "records": [
    {
      "source_ip": "192.0.2.25",
      "message_count": 212,
      "disposition": "none",
      "dkim_result": "pass",
      "spf_result": "pass",
      "header_from": "example.com",
      "auth_results": {
        "dkim": [ { "domain": "example.com", "result": "pass", "selector": "20261020a" } ],
        "spf": [ { "domain": "example.com", "result": "pass" } ]
      }
    },
    {
      "source_ip": "203.0.113.77",
      "message_count": 2,
      "disposition": "quarantine",
      "dkim_result": "fail",
      "spf_result": "fail",
      "header_from": "example.com",
      "auth_results": {
        "dkim": [],
        "spf": [ { "domain": "example.net", "result": "fail" } ]
      }
    }
  ]
}
```

## Fleet and status

For site admins: the machines behind the service. How the database cluster, the mailbox servers and the other redundant groups are doing, every node's agent and the services it reports, a map of what connects to what, enrolling a new node, and the fleet's TLS certificates.

### See high-availability status

`GET https://api.mailmasker.org/api/v1/admin/ha/status`

The database cluster, its connection poolers, the mailbox servers and the internet edges, node by node.

- Who can use it: Site admins
- Area: Fleet and status
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-ha-status

Four groups, always in this order, each listing its nodes with a `role`, a `state` and whether it is `healthy`, plus a `note` on how the group works. For the database, `leader` is the node taking writes, and each node's role, state and replication lag come live from the cluster itself; `unreachable` means that node didn't answer. For the mailbox servers, `leader` is the active server's address.

#### Good to know

- For the poolers, mailbox servers and edges, `healthy` only means the node's agent checked in within the last 3 minutes, not that the service itself answered.
- The edges are not a failover group. Exactly one should be powered on at a time, and their `note` says whether that is so.
- Each call asks the database nodes directly, so one that is down can slow the answer by several seconds.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/admin/ha/status' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/admin/ha/status',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
{
  "components": [
    {
      "name": "PostgreSQL (Patroni)",
      "kind": "patroni",
      "vip": "192.0.2.30",
      "leader": "prod-homelab06",
      "members": [
        { "name": "prod-homelab06", "address": "192.0.2.6", "role": "leader", "state": "running",
          "lag": null, "timeline": 4, "healthy": true, "last_heartbeat": "2026-11-03 21:42:10.123456-06" },
        { "name": "prod-homelab07", "address": "192.0.2.7", "role": "replica", "state": "streaming",
          "lag": 0, "timeline": 4, "healthy": true, "last_heartbeat": "2026-11-03 21:41:52.410028-06" }
      ],
      "note": "Patroni + etcd + keepalived VIP 192.0.2.30 (follows the leader; apps reach it via pgbouncer). Role/state/lag are Patroni's own cluster view via each node's :8008/cluster REST; \"unreachable\" means that node's Patroni REST did not answer."
    },
    {
      "name": "Connection poolers (pgbouncer)",
      "kind": "pgbouncer",
      "members": [
        { "name": "prod-homelab09", "address": "192.0.2.9", "role": "pooler", "state": "up",
          "healthy": true, "last_heartbeat": "2026-11-03 21:41:58.907311-06" }
      ],
      "note": "Every pooler fronts the database VIP, so any one keeps serving across a leader failover. Liveness is the fleet agent heartbeat (:6432 manifest)."
    },
    {
      "name": "Mailbox servers (dovecot)",
      "kind": "dovecot",
      "leader": "192.0.2.62",
      "members": [
        { "name": "prod-homelabvm62", "address": "192.0.2.62", "role": "active", "state": "up",
          "healthy": true, "last_heartbeat": "2026-11-03 21:42:01.335871-06" }
      ],
      "note": "Active/passive: the active host serves ALL mail paths (IMAP/IMAPS, LMTP delivery, SASL submission auth) and they fail over together via homelab-haproxy, which marks the other two `backup`. Same source as the Dovecot tab, so the two cannot disagree."
    },
    {
      "name": "Internet-facing edges",
      "kind": "edges",
      "members": [
        { "name": "prod-homelab01", "address": "192.0.2.1", "role": "powered on", "state": "serving",
          "healthy": true, "last_heartbeat": "2026-11-03 21:42:04.661290-06" },
        { "name": "prod-homelab02", "address": "192.0.2.2", "role": "off", "state": "powered off",
          "healthy": false, "last_heartbeat": "2026-10-30 08:12:44.310925-05" }
      ],
      "note": "NOT automatic failover, by design: exactly ONE edge is powered on at a time and is swapped by hand (pct shutdown one, pct start another). Both public addresses arrive by DHCP from the AT&T reservations keyed to that node's eth7/eth6 MACs, so whichever node is up answers on them. keepalived was removed deliberately. Exactly one is up, which is correct."
    }
  ]
}
```

### See the mailbox servers

`GET https://api.mailmasker.org/api/v1/admin/dovecot/status`

Which mailbox server is active, which are standing by, and whether each is up.

- Who can use it: Site admins
- Area: Fleet and status
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-dovecot-status

One mailbox server is active and does all the mail work: reading mail, delivering it, and checking passwords for sending. The others stand by, and if the active one goes down all of that moves to a standby together. `paths` lists those mail services and their ports, and `note` explains the arrangement.

#### Good to know

- `healthy` means the server's agent checked in within the last 3 minutes; it doesn't test the mail service itself.
- `role` is the planned layout from the api's own settings, not a live reading: if the mail service fails over by itself, this still names the old server as active.
- `last_heartbeat` is null for a server whose agent has never checked in.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/admin/dovecot/status' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/admin/dovecot/status',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin dovecot status
```

#### Response

200 OK:

```json
{
  "active_address": "192.0.2.62",
  "hosts": [
    {
      "name": "prod-homelabvm62",
      "address": "192.0.2.62",
      "role": "active",
      "healthy": true,
      "last_heartbeat": "2026-11-03 21:42:10.123456-06"
    },
    {
      "name": "prod-homelabvm63",
      "address": "192.0.2.63",
      "role": "standby",
      "healthy": true,
      "last_heartbeat": "2026-11-03 21:41:58.907311-06"
    }
  ],
  "paths": [
    { "name": "IMAP / IMAPS", "ports": "143, 993", "via": "HAProxy active/passive" },
    { "name": "LMTP delivery", "ports": "24", "via": "HAProxy active/passive" },
    { "name": "SASL submission auth", "ports": "12345", "via": "HAProxy active/passive" }
  ],
  "note": "Active/passive: exactly one host (the active one) serves ALL mail -- IMAP/IMAPS reads, LMTP delivery, and SASL submission auth -- so its local maildir index stays authoritative (shared storage, per-host indexes). All three paths fail over together to a standby if the active host goes down. Enforced by homelab-haproxy active_passive; keep the pool here in sync with its backends.yml."
}
```

### List nodes

`GET https://api.mailmasker.org/api/v1/admin/agent/hosts`

Every node whose agent has ever checked in, and when it last did.

- Who can use it: Site admins
- Area: Fleet and status
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-list-nodes

In name order: each node's address, the port its agent listens on, the agent's version, and when it last checked in. Agents check in about once a minute, so a `last_heartbeat` more than a few minutes old means that node has gone quiet.

#### Good to know

- A node that has been switched off stays on the list; only its `last_heartbeat` stops moving.
- A node missing from the list has never finished enrolling.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/admin/agent/hosts' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/admin/agent/hosts',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin fleet status
```

#### Response

200 OK:

```json
[
  {
    "hostname": "prod-homelab12",
    "address": "192.0.2.12",
    "agent_port": 2520,
    "agent_version": "0.1.9",
    "last_heartbeat": "2026-11-03 21:42:10.123456-06"
  },
  {
    "hostname": "prod-homelab13",
    "address": "192.0.2.13",
    "agent_port": 2520,
    "agent_version": "0.1.9",
    "last_heartbeat": "2026-11-03 21:41:47.880315-06"
  }
]
```

### List services on every node

`GET https://api.mailmasker.org/api/v1/admin/agent/status`

Every service each node declares, and whether its agent found it running.

- Who can use it: Site admins
- Area: Fleet and status
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-service-status

One row per service per node, by service name, as each node last reported it. `expected` is 1 for a service the node's packages declare, and `actual` is 1 when the agent found it running (its system service active, or its port answering). For a proxy, `fronts` lists where it passes traffic on to.

#### Good to know

- Yes-or-no fields come back as 1 or 0.
- A node that stops checking in keeps its last report, so its services can look fine long after it went quiet. Compare `checked_at` with the time, or see the fleet map, which marks such nodes stale.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/admin/agent/status' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/admin/agent/status',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin fleet status
```

#### Response

200 OK:

```json
[
  {
    "hostname": "prod-homelab09",
    "service_name": "pgbouncer",
    "package_name": "homelab-pgbouncer",
    "kind": "pgbouncer",
    "expected": 1,
    "actual": 1,
    "description": "PgBouncer connection pooler in front of the shared homelab database",
    "fronts": null,
    "checked_at": "2026-11-03 21:41:58.907311-06"
  },
  {
    "hostname": "prod-homelab03",
    "service_name": "webproxy-myaccount.example.com",
    "package_name": "homelab-webproxy",
    "kind": "http-proxy-vhost",
    "expected": 1,
    "actual": 1,
    "description": "HTTPS vhost for myaccount.example.com",
    "fronts": ["192.0.2.20:2503", "192.0.2.21:2503"],
    "checked_at": "2026-11-03 21:42:10.123456-06"
  }
]
```

### List services that aren't running

`GET https://api.mailmasker.org/api/v1/admin/agent/status/mismatches`

Only the services whose running state doesn't match what their node declares.

- Who can use it: Site admins
- Area: Fleet and status
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-service-problems

The rows of the service list where `expected` and `actual` differ. Agents report only the services their node declares, so in practice each row is a service that should be running and isn't. An empty list means everything declared is running.

#### Good to know

- Yes-or-no fields come back as 1 or 0.
- A node that has gone quiet keeps its last report, so its services don't show up here; look for it in the node list or on the fleet map.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/admin/agent/status/mismatches' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/admin/agent/status/mismatches',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin fleet drift
```

#### Response

200 OK:

```json
[
  {
    "hostname": "prod-homelab13",
    "service_name": "drive",
    "package_name": "homelab-drive",
    "kind": "http_service",
    "expected": 1,
    "actual": 0,
    "description": "File storage web app + JSON API, serves /api/v1/drive/*",
    "fronts": null,
    "checked_at": "2026-11-03 21:41:47.880315-06"
  }
]
```

### Map the fleet

`GET https://api.mailmasker.org/api/v1/admin/agent/topology`

Every machine, the services on it, and which service talks to which, as a graph.

- Who can use it: Site admins
- Area: Fleet and status
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-fleet-topology

For drawing a map. `nodes` holds one entry per service per machine, named `host/service`; `edges` say which talks to which, with a `kind` of proxy, http, mail or dns; `hosts` groups the same entries under the machine they run on. The connections come from where the proxies say they pass traffic, plus a fixed list of the calls the services make to each other.

#### Good to know

- A service is `healthy` when it is running and its machine checked in within the last `heartbeat_stale_after_sec` seconds; a machine past that is marked `stale`.
- A service found only as somewhere a proxy passes traffic, on a machine with no agent, has `monitored` false and `healthy` null.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/admin/agent/topology' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/admin/agent/topology',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin fleet topology
```

#### Response

200 OK:

```json
{
  "heartbeat_stale_after_sec": 300,
  "hosts": [
    {
      "hostname": "prod-homelab42",
      "address": "192.0.2.42",
      "agent_version": "0.1.9",
      "last_heartbeat": "2026-11-03 21:42:10.123456-06",
      "stale": false,
      "monitored": true,
      "services": [
        { "id": "prod-homelab42/roundcube-nginx", "host": "prod-homelab42", "service": "roundcube-nginx",
          "kind": "http_service", "package": "homelab-roundcube", "address": "192.0.2.42",
          "expected": true, "actual": true, "healthy": true, "monitored": true,
          "description": "Roundcube's internal-only nginx vhost, bridging homelab-webproxy to PHP-FPM" },
        { "id": "prod-homelab42/roundcube-php-fpm", "host": "prod-homelab42", "service": "roundcube-php-fpm",
          "kind": "http_service", "package": "homelab-roundcube", "address": "192.0.2.42",
          "expected": true, "actual": true, "healthy": true, "monitored": true,
          "description": "PHP-FPM pool serving Roundcube itself" }
      ]
    }
  ],
  "nodes": [
    { "id": "prod-homelab42/roundcube-nginx", "host": "prod-homelab42", "service": "roundcube-nginx",
      "kind": "http_service", "package": "homelab-roundcube", "address": "192.0.2.42",
      "expected": true, "actual": true, "healthy": true, "monitored": true,
      "description": "Roundcube's internal-only nginx vhost, bridging homelab-webproxy to PHP-FPM" },
    { "id": "prod-homelab42/roundcube-php-fpm", "host": "prod-homelab42", "service": "roundcube-php-fpm",
      "kind": "http_service", "package": "homelab-roundcube", "address": "192.0.2.42",
      "expected": true, "actual": true, "healthy": true, "monitored": true,
      "description": "PHP-FPM pool serving Roundcube itself" }
  ],
  "edges": [
    { "from": "prod-homelab42/roundcube-nginx", "to": "prod-homelab42/roundcube-php-fpm", "kind": "http" }
  ]
}
```

### Enroll a new node

`POST https://api.mailmasker.org/api/v1/admin/agent/enroll`

Get a one-time code that lets a new node's agent sign itself in.

- Who can use it: Site admins
- Area: Fleet and status
- Answers 201 Created when it works
- Page: https://developer.mailmasker.org/admin-enroll-node

Do this before installing homelab-agent on the new node. The code is tied to the host name you give. The agent there trades it for its own sign-in, then checks in by itself about once a minute. Enrolling a host name again later (after its agent has been offline too long, say) keeps the same identity.

#### Parameters

| Name | In | Required | What it is | Example |
|---|---|---|---|---|
| `hostname` | body | yes | The node's name, exactly as its agent is set up with it. | `prod-homelab12` |
| `ttl_minutes` | body | no | How many minutes the code stays usable. The default is 10. |  |

#### Good to know

- The code works once, and only until it expires.
- Hand it over by preseeding the homelab-agent/enrollment_code answer before installing the agent; homelab-cli prints the exact line to run.

#### Examples

cURL:

```bash
curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/agent/enroll' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"hostname":"prod-homelab12"}'
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->post('https://api.mailmasker.org/api/v1/admin/agent/enroll',
    { Authorization => "Bearer $ENV{TOKEN}" },
    json => { hostname => 'prod-homelab12' })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

```bash
homelab-cli admin agent enroll prod-homelab12
```

#### Response

201 Created:

```json
{ "code": "<one-time token>", "hostname": "prod-homelab12", "expires_in_minutes": 10 }
```

### List the fleet's certificates

`GET https://api.mailmasker.org/api/v1/admin/certstore`

Each TLS certificate the fleet hands out, soonest to expire first.

- Who can use it: Site admins
- Area: Fleet and status
- Answers 200 OK when it works
- Page: https://developer.mailmasker.org/admin-list-certificates

One entry per certificate name (`lineage`): the certificate the servers using that name should have now, the names it covers, when it expires and how many days are left. The issuing node publishes each renewal here and the servers pick it up from here; `sha256_fullchain` is what each of them compares with its own copy to tell whether it is out of date. No key material is ever included.

#### Good to know

- Only the current certificate for each name is listed, not older ones.
- `published_by` is the issuing node's agent, and `promoted_at` is when this certificate became the current one.
- `days_remaining` counts whole days, and goes below 0 once a certificate is more than a day past its expiry.

#### Examples

cURL:

```bash
curl -s 'https://api.mailmasker.org/api/v1/admin/certstore' \
  -H "Authorization: Bearer $TOKEN"
```

Perl:

```perl
use Mojo::Base -strict;
use Mojo::UserAgent;

my $ua  = Mojo::UserAgent->new;
my $res = $ua->get('https://api.mailmasker.org/api/v1/admin/certstore',
    { Authorization => "Bearer $ENV{TOKEN}" })->result;
die $res->code, ' ', ($res->json('/error') // $res->message), "\n" unless $res->is_success;
print $res->body, "\n";
```

homelab-cli:

There is no homelab-cli command for this yet.

#### Response

200 OK:

```json
[
  {
    "lineage": "mail.example.com",
    "serial": "04F3A2B19C7D5E6F8091A2B3C4D5E6F70812",
    "not_before": "2026-10-15 02:12:44-05",
    "not_after": "2027-01-13 01:12:43-06",
    "days_remaining": 70,
    "domains": ["mail.example.com", "imap.example.com", "smtp.example.com"],
    "sha256_fullchain": "6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f",
    "published_by": "agent+prod-homelab46@example.com",
    "published_at": "2026-10-15 03:12:51.208734-05",
    "promoted_at": "2026-10-15 03:12:51.208734-05"
  }
]
```
