{"api":"https://api.mailmasker.org","areas":[{"abilities":[{"area":"Sign-in and tokens","area_slug":"sign-in","description":"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.\n","examples":{"cli":"homelab-cli register newuser@example.com --invite-token \"$INVITE_TOKEN\"","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/auth/register' \\\n  -H 'Content-Type: application/json' \\\n  -d \"{\\\"email\\\":\\\"newuser@example.com\\\",\\\"password\\\":\\\"$PASSWORD\\\",\\\"invite_token\\\":\\\"$INVITE_TOKEN\\\",\\\"recovery_email\\\":\\\"personal@example.com\\\"}\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/auth/register',\n    json => { email => 'newuser@example.com', password => $ENV{PASSWORD}, invite_token => $ENV{INVITE_TOKEN}, recovery_email => 'personal@example.com' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"create-account","method":"POST","notes":["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."],"params":[{"about":"The new account's address, which is also what it signs in with.","example":"newuser@example.com","in":"body","name":"email","required":true},{"about":"The new account's password.","example":"$PASSWORD","in":"body","name":"password","required":true},{"about":"The token from an invite link.","example":"$INVITE_TOKEN","in":"body","name":"invite_token","required":true},{"about":"An address outside this mail system, where a password-reset link can be sent.","example":"personal@example.com","in":"body","name":"recovery_email","required":false}],"path":"/api/v1/auth/register","response":"{ \"id\": 42, \"email\": \"newuser@example.com\" }\n","status":201,"summary":"Make a new account with an invite, an address and a password.","title":"Create an account","who":"anyone"},{"area":"Sign-in and tokens","area_slug":"sign-in","description":"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.\n","examples":{"cli":"homelab-cli login you@example.com","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/auth/login' \\\n  -H 'Content-Type: application/json' \\\n  -d \"{\\\"email\\\":\\\"you@example.com\\\",\\\"password\\\":\\\"$PASSWORD\\\"}\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/auth/login',\n    json => { email => 'you@example.com', password => $ENV{PASSWORD} })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"sign-in","method":"POST","notes":["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."],"params":[{"about":"Your address.","example":"you@example.com","in":"body","name":"email","required":true},{"about":"Your password.","example":"$PASSWORD","in":"body","name":"password","required":true}],"path":"/api/v1/auth/login","response":"{\n  \"success\": true,\n  \"token\": \"<token>\",\n  \"refresh_token\": \"<refresh token>\",\n  \"expires_in\": 86400,\n  \"email\": \"you@example.com\"\n}\n","status":200,"summary":"Trade your address and password for a token and a refresh token.","title":"Sign in","who":"anyone"},{"area":"Sign-in and tokens","area_slug":"sign-in","description":"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.\n","examples":{"cli":"homelab-cli whoami","curl":"curl -s 'https://api.mailmasker.org/api/v1/auth/introspect' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/auth/introspect',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"check-token","method":"GET","notes":["A missing, run-out or signed-out token answers 401."],"params":[{"about":"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.","example":null,"in":"query","name":"capability","required":false}],"path":"/api/v1/auth/introspect","response":"{\n  \"email\": \"you@example.com\",\n  \"exp\": 1793850130,\n  \"roles\": [\"user\"],\n  \"jti\": \"7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f\"\n}\n","status":200,"summary":"Who a token belongs to, when it runs out, and the account's roles.","title":"Check your token","who":"signed-in"},{"area":"Sign-in and tokens","area_slug":"sign-in","description":"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`.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/auth/refresh' \\\n  -H 'Content-Type: application/json' \\\n  -d \"{\\\"refresh_token\\\":\\\"$REFRESH_TOKEN\\\"}\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/auth/refresh',\n    json => { refresh_token => $ENV{REFRESH_TOKEN} })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"refresh-token","method":"POST","notes":["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."],"params":[{"about":"The refresh token from signing in, or from your last refresh.","example":"$REFRESH_TOKEN","in":"body","name":"refresh_token","required":true}],"path":"/api/v1/auth/refresh","response":"{\n  \"success\": true,\n  \"token\": \"<token>\",\n  \"refresh_token\": \"<refresh token>\",\n  \"expires_in\": 86400\n}\n","status":200,"summary":"Trade your refresh token for a new token and a new refresh token.","title":"Refresh your token","who":"anyone"},{"area":"Sign-in and tokens","area_slug":"sign-in","description":"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.\n","examples":{"cli":"homelab-cli logout","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/auth/logout' \\\n  -H 'Content-Type: application/json' \\\n  -d \"{\\\"refresh_token\\\":\\\"$REFRESH_TOKEN\\\"}\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/auth/logout',\n    json => { refresh_token => $ENV{REFRESH_TOKEN} })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"sign-out","method":"POST","notes":["The answer is the same for a refresh token that was already signed out, or never existed."],"params":[{"about":"The refresh token of the session to end.","example":"$REFRESH_TOKEN","in":"body","name":"refresh_token","required":true}],"path":"/api/v1/auth/logout","response":"{ \"success\": true }\n","status":200,"summary":"End the session a refresh token belongs to.","title":"Sign out","who":"anyone"},{"area":"Sign-in and tokens","area_slug":"sign-in","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/auth/password' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d \"{\\\"current_password\\\":\\\"$PASSWORD\\\",\\\"new_password\\\":\\\"$NEW_PASSWORD\\\"}\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/auth/password',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { current_password => $ENV{PASSWORD}, new_password => $ENV{NEW_PASSWORD} })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"change-password","method":"POST","notes":["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."],"params":[{"about":"Your password now.","example":"$PASSWORD","in":"body","name":"current_password","required":true},{"about":"The new password, at least 8 characters.","example":"$NEW_PASSWORD","in":"body","name":"new_password","required":true}],"path":"/api/v1/auth/password","response":"{ \"ok\": true }\n","status":200,"summary":"Set a new password, given your current one.","title":"Change your password","who":"signed-in"}],"area":"Sign-in and tokens","intro":"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.\n","slug":"sign-in"},{"abilities":[{"area":"Sessions","area_slug":"sessions","description":"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.\n","examples":{"cli":"homelab-cli sessions list","curl":"curl -s 'https://api.mailmasker.org/api/v1/auth/sessions' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/auth/sessions',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"list-sessions","method":"GET","notes":["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."],"params":[{"about":"Another account's address, to list its sessions instead. Site admins only; anyone else gets 403.","example":null,"in":"query","name":"user","required":false}],"path":"/api/v1/auth/sessions","response":"[\n  {\n    \"jti\": \"7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f\",\n    \"user_agent\": \"Mozilla/5.0 (X11; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0\",\n    \"ip_address\": \"203.0.113.7\",\n    \"first_seen_at\": \"2026-11-03 21:40:02.654321-06\",\n    \"expires_at\": \"2026-11-04 21:40:02.654321-06\",\n    \"current\": true\n  },\n  {\n    \"jti\": \"c41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41a\",\n    \"user_agent\": \"homelab-cli/0.1.0 (Linux 6.8.0-49-generic)\",\n    \"ip_address\": \"198.51.100.23\",\n    \"first_seen_at\": \"2026-11-01 09:15:42.246810-06\",\n    \"expires_at\": \"2026-11-04 08:05:31.135792-06\",\n    \"current\": false\n  }\n]\n","status":200,"summary":"Every place you are signed in, the newest first.","title":"List your sessions","who":"signed-in"},{"area":"Sessions","area_slug":"sessions","description":"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.\n","examples":{"cli":"homelab-cli sessions revoke c41a9e07b2d8","curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/auth/sessions/c41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41a' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/auth/sessions/c41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41a',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"end-session","method":"DELETE","notes":["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."],"params":[{"about":"The session's id, in full, as your session list shows it.","example":"c41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41ac41a","in":"path","name":"jti","required":true},{"about":"Another account's address, to end one of its sessions. Site admins only; anyone else gets 403.","example":null,"in":"query","name":"user","required":false}],"path":"/api/v1/auth/sessions/:jti","response":"{ \"ok\": true }\n","status":200,"summary":"Sign one of your sessions out at once, wherever it is.","title":"End a session","who":"signed-in"},{"area":"Sessions","area_slug":"sessions","description":"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.\n","examples":{"cli":"homelab-cli sessions revoke --all-others","curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/auth/sessions?except_current=true' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/auth/sessions',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    form => { except_current => 'true' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"sign-out-other-sessions","method":"DELETE","notes":["Leaving out except_current, or sending anything but true or false, answers 400.","It only ever ends your own sessions."],"params":[{"about":"true to keep the session making this call, false to end it too. There is no default.","example":"true","in":"query","name":"except_current","required":true}],"path":"/api/v1/auth/sessions","response":"{ \"ok\": true, \"revoked\": 2 }\n","status":200,"summary":"End every session but this one, or every one including it.","title":"Sign out your other sessions","who":"signed-in"}],"area":"Sessions","intro":"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.\n","slug":"sessions"},{"abilities":[{"area":"Your account","area_slug":"account","description":"`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`.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/account/summary' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/account/summary',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"account-summary","method":"GET","notes":[],"params":[],"path":"/api/v1/account/summary","response":"{\n  \"email\": \"you@example.com\",\n  \"created_at\": \"2026-09-27 14:03:51.402117-05\",\n  \"recovery_email\": \"personal@example.com\",\n  \"active\": true,\n  \"roles\": [\"user\"]\n}\n","status":200,"summary":"Your address, when the account was made, your recovery address and your roles.","title":"See your account","who":"signed-in"},{"area":"Your account","area_slug":"account","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/account/recovery-email' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"recovery_email\":\"personal@example.com\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/account/recovery-email',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { recovery_email => 'personal@example.com' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"set-recovery-email","method":"POST","notes":["Something that doesn't look like an address answers 400."],"params":[{"about":"The address to use. An empty string or null removes it, and so does leaving it out.","example":"personal@example.com","in":"body","name":"recovery_email","required":false}],"path":"/api/v1/account/recovery-email","response":"{ \"ok\": true, \"recovery_email\": \"personal@example.com\" }\n","status":200,"summary":"Choose where a password-reset link is sent, or remove it.","title":"Set your recovery address","who":"signed-in"},{"area":"Your account","area_slug":"account","description":"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.\n","examples":{"cli":"homelab-cli audit list --since 2026-11-01","curl":"curl -s 'https://api.mailmasker.org/api/v1/audit/log?since=2026-11-01' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/audit/log',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    form => { since => '2026-11-01' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"activity-log","method":"GET","notes":["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."],"params":[{"about":"Only entries this account did. Anyone but yourself needs the audit.view permission.","example":null,"in":"query","name":"user","required":false},{"about":"Only entries that touched this account, including what a site admin did to it. Anyone but yourself needs the audit.view permission.","example":null,"in":"query","name":"affecting","required":false},{"about":"Only entries at or after this time, such as 2026-11-01 or 2026-11-01 16:00:00-06.","example":"2026-11-01","in":"query","name":"since","required":false},{"about":"Only entries at or before this time, written the same way.","example":null,"in":"query","name":"until","required":false},{"about":"Only entries with this action, such as auth.login or file.delete.","example":null,"in":"query","name":"action","required":false}],"path":"/api/v1/audit/log","response":"[\n  {\n    \"id\": 18342,\n    \"occurred_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"actor_email\": \"you@example.com\",\n    \"affected_user\": \"you@example.com\",\n    \"jti\": \"7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f\",\n    \"action\": \"recipient_access.block\",\n    \"resource_type\": \"recipient_access\",\n    \"resource_id\": \"shop@example.com\",\n    \"source_service\": \"homelab-domain-admin\",\n    \"ip_address\": \"203.0.113.7\",\n    \"user_agent\": \"homelab-cli/0.1.0 (Linux 6.8.0-49-generic)\",\n    \"detail\": { \"tier\": \"self_service\", \"action\": \"REJECT\", \"reason\": \"Sells my address\" }\n  },\n  {\n    \"id\": 18297,\n    \"occurred_at\": \"2026-11-03 21:40:02.654321-06\",\n    \"actor_email\": \"you@example.com\",\n    \"affected_user\": \"you@example.com\",\n    \"jti\": \"7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f7d3f\",\n    \"action\": \"auth.login\",\n    \"resource_type\": \"user\",\n    \"resource_id\": \"42\",\n    \"source_service\": \"homelab-api\",\n    \"ip_address\": \"203.0.113.7\",\n    \"user_agent\": \"homelab-cli/0.1.0 (Linux 6.8.0-49-generic)\",\n    \"detail\": null\n  }\n]\n","status":200,"summary":"What your account has done and when, the newest first.","title":"See your activity log","who":"signed-in"}],"area":"Your account","intro":"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.\n","slug":"account"},{"abilities":[{"area":"Your addresses and blocking","area_slug":"blocking","description":"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.\n","examples":{"cli":"homelab-cli mail allowed-senders","curl":"curl -s 'https://api.mailmasker.org/api/v1/domains/mail-aliases/mine' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/domains/mail-aliases/mine',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"my-addresses","method":"GET","notes":[],"params":[],"path":"/api/v1/domains/mail-aliases/mine","response":"{\n  \"send\": {\n    \"addresses\": [\"you@example.com\", \"shop@example.com\"],\n    \"domains\": [\"@example.com\"]\n  },\n  \"receive_only\": {\n    \"addresses\": [],\n    \"domains\": []\n  }\n}\n","status":200,"summary":"Every address you can send from, and the ones you can only receive at.","title":"List your addresses","who":"signed-in"},{"area":"Your addresses and blocking","area_slug":"blocking","description":"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.\n","examples":{"cli":"homelab-cli mail blocked --search shop","curl":"curl -s 'https://api.mailmasker.org/api/v1/domains/recipient-access/mine?q=shop' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/domains/recipient-access/mine',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    form => { q => 'shop' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"blocked-list","method":"GET","notes":[],"params":[{"about":"Only entries whose address contains this text, ignoring case.","example":"shop","in":"query","name":"q","required":false}],"path":"/api/v1/domains/recipient-access/mine","response":"[\n  {\n    \"recipient\": \"shop@example.com\",\n    \"action\": \"REJECT\",\n    \"reason\": \"blocked via inbox link\",\n    \"created_by\": \"you@example.com\",\n    \"user_email\": \"you@example.com\",\n    \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n  }\n]\n","status":200,"summary":"The addresses you've blocked or allowed, alphabetically.","title":"List your blocked addresses","who":"signed-in"},{"area":"Your addresses and blocking","area_slug":"blocking","description":"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.\n","examples":{"cli":"homelab-cli mail block shop@example.com --reason \"This address is no longer in use\"","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/recipient-access/mine' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"recipient\":\"shop@example.com\",\"action\":\"REJECT\",\"reason\":\"This address is no longer in use\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/domains/recipient-access/mine',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { recipient => 'shop@example.com', action => 'REJECT', reason => 'This address is no longer in use' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"block-address","method":"POST","notes":["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."],"params":[{"about":"The address to block. It must be one of yours.","example":"shop@example.com","in":"body","name":"recipient","required":true},{"about":"REJECT to block. (OK would make it an allow entry instead.)","example":"REJECT","in":"body","name":"action","required":true},{"about":"Shown to whoever sends mail to it, in the rejection.","example":"This address is no longer in use","in":"body","name":"reason","required":false}],"path":"/api/v1/domains/recipient-access/mine","response":"{\n  \"recipient\": \"shop@example.com\",\n  \"action\": \"REJECT\",\n  \"reason\": \"This address is no longer in use\",\n  \"created_by\": \"you@example.com\",\n  \"user_email\": \"you@example.com\",\n  \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n}\n","status":201,"summary":"Reject all future mail sent to one of your own addresses.","title":"Block an address","who":"signed-in"},{"area":"Your addresses and blocking","area_slug":"blocking","description":"Removes your own entry for this address. Only entries you made can be removed this way; one a site admin made stays.\n","examples":{"cli":"homelab-cli mail unblock shop@example.com","curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/recipient-access/mine/shop%40example.com' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/recipient-access/mine/shop%40example.com',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"unblock-address","method":"DELETE","notes":["An address that isn't on your list answers 404."],"params":[{"about":"The address, exactly as your list shows it.","example":"shop@example.com","in":"path","name":"recipient","required":true}],"path":"/api/v1/domains/recipient-access/mine/:recipient","response":"{ \"ok\": true }\n","status":200,"summary":"Take one address off your block list, so its mail is delivered again.","title":"Unblock an address","who":"signed-in"},{"area":"Your addresses and blocking","area_slug":"blocking","description":"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`.\n","examples":{"cli":"homelab-cli mail block-link show","curl":"curl -s 'https://api.mailmasker.org/api/v1/block-link/account' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/block-link/account',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"block-link-setting","method":"GET","notes":[],"params":[],"path":"/api/v1/block-link/account","response":"{ \"enabled\": true, \"mode\": \"both\", \"domain_default\": false }\n","status":200,"summary":"Whether mail to you gets a \"block this address\" link, and how.","title":"See your inbox-link setting","who":"signed-in"},{"area":"Your addresses and blocking","area_slug":"blocking","description":"Your own choice overrides your domain's default. Send `null` to drop your choice and follow the domain default again.\n","examples":{"cli":"homelab-cli mail block-link enable","curl":"curl -s -X PUT 'https://api.mailmasker.org/api/v1/block-link/account' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"enabled\":true}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->put('https://api.mailmasker.org/api/v1/block-link/account',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { enabled => \\1 })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"block-link-set","method":"PUT","notes":[],"params":[{"about":"true for links, false for none, null to follow the domain default.","example":true,"in":"body","name":"enabled","required":true}],"path":"/api/v1/block-link/account","response":"{ \"enabled\": true, \"mode\": \"both\", \"domain_default\": false }\n","status":200,"summary":"Choose whether your incoming mail carries a \"block this address\" link.","title":"Turn inbox links on or off","who":"signed-in"}],"area":"Your addresses and blocking","intro":"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.\n","slug":"blocking"},{"abilities":[{"area":"Mail","area_slug":"mail","description":"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.\n","examples":{"cli":"homelab-cli mail list --limit 10","curl":"curl -s 'https://api.mailmasker.org/api/v1/mail/messages?limit=10' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/mail/messages',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    form => { limit => 10 })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"mail-list","method":"GET","notes":["A folder that doesn't exist answers 502, the same as when your mailbox can't be reached."],"params":[{"about":"The folder to list. INBOX if you leave it out.","example":null,"in":"query","name":"mailbox","required":false},{"about":"How many of the newest messages to return. 20 if you leave it out.","example":10,"in":"query","name":"limit","required":false}],"path":"/api/v1/mail/messages","response":"[\n  {\n    \"uid\": \"41\",\n    \"from\": \"Alex <alex@example.com>\",\n    \"subject\": \"Lunch on Friday?\",\n    \"date\": \"Tue, 3 Nov 2026 12:05:44 -0600\"\n  },\n  {\n    \"uid\": \"42\",\n    \"from\": \"billing@example.com\",\n    \"subject\": \"Your November invoice\",\n    \"date\": \"Tue, 3 Nov 2026 21:42:10 -0600\"\n  }\n]\n","status":200,"summary":"The sender, subject and date of the newest messages in one folder.","title":"List your recent messages","who":"signed-in"},{"area":"Mail","area_slug":"mail","description":"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.\n","examples":{"cli":"homelab-cli mail read 42","curl":"curl -s 'https://api.mailmasker.org/api/v1/mail/messages/42' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/mail/messages/42',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"mail-read","method":"GET","notes":["A number that isn't in that folder answers 404."],"params":[{"about":"The message's number, from List your recent messages.","example":42,"in":"path","name":"uid","required":true},{"about":"The folder the message is in. INBOX if you leave it out.","example":null,"in":"query","name":"mailbox","required":false}],"path":"/api/v1/mail/messages/:uid","response":"{\n  \"from\": \"billing@example.com\",\n  \"subject\": \"Your November invoice\",\n  \"date\": \"Tue, 3 Nov 2026 21:42:10 -0600\",\n  \"body\": \"Thanks for your order. Your invoice is attached.\"\n}\n","status":200,"summary":"One message's sender, subject, date and plain-text body.","title":"Read a message","who":"signed-in"},{"area":"Mail","area_slug":"mail","description":"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.\n","examples":{"cli":"homelab-cli mail send --to alex@example.com --subject \"Lunch on Friday?\" --body \"Are you free at noon?\"","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/mail/send' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"to\":\"alex@example.com\",\"subject\":\"Lunch on Friday?\",\"body\":\"Are you free at noon?\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/mail/send',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { to => 'alex@example.com', subject => 'Lunch on Friday?', body => 'Are you free at noon?' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"mail-send","method":"POST","notes":["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."],"params":[{"about":"The one address to send it to.","example":"alex@example.com","in":"body","name":"to","required":true},{"about":"The subject line.","example":"Lunch on Friday?","in":"body","name":"subject","required":true},{"about":"The message, as plain text.","example":"Are you free at noon?","in":"body","name":"body","required":true},{"about":"Send from this address instead of your login address. It may carry a name, as in `Shop <shop@example.com>`.","example":null,"in":"body","name":"from","required":false}],"path":"/api/v1/mail/send","response":"{ \"ok\": true }\n","status":200,"summary":"Send a plain-text email from your own address, or another one you may send from.","title":"Send a message","who":"signed-in"},{"area":"Mail","area_slug":"mail","description":"`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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/mail/usage' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/mail/usage',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"mail-usage","method":"GET","notes":["Both are 0 when no size limit is set for your mailbox."],"params":[],"path":"/api/v1/mail/usage","response":"{ \"used_bytes\": 734003200, \"limit_bytes\": 10737418240 }\n","status":200,"summary":"How much space your mail takes, and your mailbox's limit, in bytes.","title":"See your mail storage use","who":"signed-in"}],"area":"Mail","intro":"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.\n","slug":"mail"},{"abilities":[{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":"homelab-cli drive list","curl":"curl -s 'https://api.mailmasker.org/api/v1/drive/files' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/drive/files',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-list-files","method":"GET","notes":["A folder that isn't yours, or doesn't exist, answers 404."],"params":[{"about":"The folder to list. Leave it out for the top level.","example":null,"in":"query","name":"folder_id","required":false}],"path":"/api/v1/drive/files","response":"[\n  {\n    \"id\": 42,\n    \"filename\": \"notes.txt\",\n    \"size_bytes\": 1532,\n    \"mime_type\": \"text/plain\",\n    \"uploaded_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"folder_id\": null\n  },\n  {\n    \"id\": 41,\n    \"filename\": \"receipt.pdf\",\n    \"size_bytes\": 84210,\n    \"mime_type\": \"application/pdf\",\n    \"uploaded_at\": \"2026-11-02 09:15:44.551203-06\",\n    \"folder_id\": null\n  }\n]\n","status":200,"summary":"The files in one folder, or at the top level, newest first.","title":"List your files","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":"homelab-cli drive upload ./notes.txt","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/files' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -F file=@notes.txt\n","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/files',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    form => { file => { file => 'notes.txt' } })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";\n"},"id":"drive-upload-file","method":"POST","notes":["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."],"params":[{"about":"The file itself, as a multipart form field (with cURL, `-F file=@notes.txt`).","example":null,"in":"body","name":"file","required":true},{"about":"A form field naming the folder to put it in. Leave it out for the top level.","example":null,"in":"body","name":"folder_id","required":false},{"about":"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.\n","example":null,"in":"body","name":"client_mtime","required":false}],"path":"/api/v1/drive/files","response":"{\n  \"id\": 42,\n  \"filename\": \"notes.txt\",\n  \"size_bytes\": 1532,\n  \"mime_type\": \"text/plain\",\n  \"uploaded_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"content_created_at\": \"2026-10-30 08:12:03-06\",\n  \"folder_id\": null\n}\n","status":201,"summary":"Send a whole file in one request, as a form upload.","title":"Upload a file","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":"homelab-cli drive download 42 --output notes.txt","curl":"curl -s 'https://api.mailmasker.org/api/v1/drive/files/42' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/drive/files/42',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-download-file","method":"GET","notes":["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."],"params":[{"about":"The file's id, from List your files.","example":42,"in":"path","name":"id","required":true}],"path":"/api/v1/drive/files/:id","response":"(the file's contents)\n","status":200,"summary":"The file's contents, exactly as uploaded.","title":"Download a file","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":"homelab-cli drive delete 42","curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/drive/files/42' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/drive/files/42',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-delete-file","method":"DELETE","notes":["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."],"params":[{"about":"The file's id.","example":42,"in":"path","name":"id","required":true},{"about":"1 to move the file to Trash instead of deleting it for good.","example":null,"in":"query","name":"soft","required":false}],"path":"/api/v1/drive/files/:id","response":"{ \"ok\": true, \"trashed\": false }\n","status":200,"summary":"Delete one file at once and for good, or move it to Trash.","title":"Delete a file","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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`.\n","examples":{"cli":"homelab-cli drive list","curl":"curl -s 'https://api.mailmasker.org/api/v1/drive/folders' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/drive/folders',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-list-folders","method":"GET","notes":["A folder that isn't yours, or doesn't exist, answers 404."],"params":[{"about":"The folder whose subfolders you want. Leave it out for the top level.","example":null,"in":"query","name":"parent_id","required":false}],"path":"/api/v1/drive/folders","response":"[\n  { \"id\": 7, \"name\": \"Photos\", \"parent_folder_id\": null },\n  { \"id\": 9, \"name\": \"Receipts\", \"parent_folder_id\": null }\n]\n","status":200,"summary":"The folders inside one folder, or at the top level, by name.","title":"List your folders","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"Folders nest as deep as you like, but two folders in the same place can't have the same name.\n","examples":{"cli":"homelab-cli drive mkdir Receipts --parent 7","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/folders' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"name\":\"Receipts\",\"parent_folder_id\":7}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/folders',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { name => 'Receipts', parent_folder_id => 7 })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-create-folder","method":"POST","notes":["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."],"params":[{"about":"The new folder's name.","example":"Receipts","in":"body","name":"name","required":true},{"about":"The folder to make it in. Leave it out, or send null, for the top level.","example":7,"in":"body","name":"parent_folder_id","required":false}],"path":"/api/v1/drive/folders","response":"{ \"id\": 9, \"name\": \"Receipts\", \"parent_folder_id\": 7 }\n","status":201,"summary":"Make a new folder, at the top level or inside another.","title":"Create a folder","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":"homelab-cli drive rmdir 7","curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/drive/folders/7' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/drive/folders/7',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-delete-folder","method":"DELETE","notes":["A folder that isn't yours, doesn't exist or is already in Trash answers 404."],"params":[{"about":"The folder's id.","example":7,"in":"path","name":"id","required":true},{"about":"1 to move the folder to Trash instead of deleting it for good.","example":null,"in":"query","name":"soft","required":false}],"path":"/api/v1/drive/folders/:id","response":"{ \"ok\": true, \"trashed\": false }\n","status":200,"summary":"Delete a folder and everything inside it, for good or to Trash.","title":"Delete a folder","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"`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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/drive/usage' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/drive/usage',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-usage","method":"GET","notes":[],"params":[],"path":"/api/v1/drive/usage","response":"{ \"used_bytes\": 2147483648, \"limit_bytes\": 1099511627776 }\n","status":200,"summary":"How much space your files take, and your limit, in bytes.","title":"See your storage use","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":"homelab-cli drive upload ./backup.iso","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/uploads' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"filename\":\"backup.iso\",\"total_size\":4294967296}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/uploads',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { filename => 'backup.iso', total_size => 4294967296 })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-upload-start","method":"POST","notes":["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."],"params":[{"about":"The file's name in Drive. Only the last part of a path is kept.","example":"backup.iso","in":"body","name":"filename","required":true},{"about":"The file's exact size, in bytes.","example":4294967296,"in":"body","name":"total_size","required":true},{"about":"The folder to put it in. Leave it out for the top level.","example":null,"in":"body","name":"folder_id","required":false}],"path":"/api/v1/drive/uploads","response":"{\n  \"upload_id\": \"0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f\",\n  \"offset\": 0,\n  \"chunk_size\": 8388608\n}\n","status":201,"summary":"Open an upload session, to send a big file in pieces you can resume.","title":"Start a big upload","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"`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.\n","examples":{"cli":"homelab-cli drive upload ./backup.iso","curl":"curl -s 'https://api.mailmasker.org/api/v1/drive/uploads/0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/drive/uploads/0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-upload-status","method":"GET","notes":["An upload that isn't yours, or no longer exists, answers 404."],"params":[{"about":"The upload_id from Start a big upload.","example":"0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f","in":"path","name":"id","required":true}],"path":"/api/v1/drive/uploads/:id","response":"{\n  \"upload_id\": \"0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f\",\n  \"state\": \"open\",\n  \"offset\": 16777216,\n  \"total_size\": 4294967296,\n  \"filename\": \"backup.iso\",\n  \"chunk_size\": 8388608\n}\n","status":200,"summary":"How much of an upload has arrived, so you know where to carry on.","title":"Check a big upload","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":"homelab-cli drive upload ./backup.iso","curl":"curl -s -X PATCH 'https://api.mailmasker.org/api/v1/drive/uploads/0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Upload-Offset: 0' \\\n  -H 'Content-Type: application/octet-stream' \\\n  --data-binary @piece-1.bin\n","perl":"use Mojo::Base -strict;\nuse Mojo::File qw(path);\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->patch('https://api.mailmasker.org/api/v1/drive/uploads/0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f',\n    { Authorization => \"Bearer $ENV{TOKEN}\", 'Upload-Offset' => 0,\n      'Content-Type' => 'application/octet-stream' },\n    path('piece-1.bin')->slurp)->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";\n"},"id":"drive-upload-piece","method":"PATCH","notes":["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."],"params":[{"about":"The upload_id from Start a big upload.","example":"0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f","in":"path","name":"id","required":true},{"about":"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.\n","example":null,"in":"body","name":"chunk","required":true}],"path":"/api/v1/drive/uploads/:id","response":"{ \"offset\": 25165824, \"done\": false }\n","status":200,"summary":"Add the next piece of the file to an open upload.","title":"Send a piece of a big upload","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/drive/uploads/0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/drive/uploads/0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-upload-cancel","method":"DELETE","notes":["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."],"params":[{"about":"The upload_id from Start a big upload.","example":"0b6f3c1e-8d2a-4f7b-9e5c-3a1d2b4c6e8f","in":"path","name":"id","required":true}],"path":"/api/v1/drive/uploads/:id","response":"{ \"ok\": true }\n","status":200,"summary":"Give up on an unfinished upload and throw away what was sent.","title":"Cancel a big upload","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":"homelab-cli drive append 41 42 43 --output backup.iso","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/append-jobs' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"file_ids\":[41,42,43],\"output_name\":\"backup.iso\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/append-jobs',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { file_ids => [41, 42, 43], output_name => 'backup.iso' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-combine","method":"POST","notes":["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."],"params":[{"about":"At least two of your file ids, in the order their bytes should go.","example":[41,42,43],"in":"body","name":"file_ids","required":true},{"about":"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.\n","example":"backup.iso","in":"body","name":"output_name","required":false},{"about":"The folder to put the new file in. Left out, it goes in the first piece's folder.","example":null,"in":"body","name":"folder_id","required":false}],"path":"/api/v1/drive/append-jobs","response":"{ \"id\": 12, \"output_name\": \"backup.iso\" }\n","status":202,"summary":"Join several files end to end into a new file, in the order you give.","title":"Combine files into one","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"`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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/drive/append-jobs/12' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/drive/append-jobs/12',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-combine-status","method":"GET","notes":["A job that isn't yours, or doesn't exist, answers 404."],"params":[{"about":"The job id from Combine files into one.","example":12,"in":"path","name":"id","required":true}],"path":"/api/v1/drive/append-jobs/:id","response":"{\n  \"id\": 12,\n  \"state\": \"processing\",\n  \"output_name\": \"backup.iso\",\n  \"received_bytes\": 1073741824,\n  \"total_bytes\": 4294967296,\n  \"file_id\": null,\n  \"error\": null\n}\n","status":200,"summary":"Whether a combine has finished, and how far along it is.","title":"See how a combine is going","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/zip-jobs' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"file_ids\":[41,42],\"folder_ids\":[7]}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/zip-jobs',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { file_ids => [41, 42], folder_ids => [7] })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-zip","method":"POST","notes":["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."],"params":[{"about":"Ids of files to put in the zip. Give this, folder_ids, or both.","example":[41,42],"in":"body","name":"file_ids","required":false},{"about":"Ids of folders to put in the zip, with everything inside them.","example":[7],"in":"body","name":"folder_ids","required":false}],"path":"/api/v1/drive/zip-jobs","response":"{\n  \"id\": 318,\n  \"output_name\": \"drive-export-you-example-com-1793763730.zip\",\n  \"dest_path\": \"Archives/drive-export-you-example-com-1793763730.zip\"\n}\n","status":201,"summary":"Build one zip of the files and folders you pick, saved into your Archives folder.","title":"Zip files and folders","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"`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`.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/drive/zip-jobs/318' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/drive/zip-jobs/318',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-zip-status","method":"GET","notes":["A job that isn't yours, or doesn't exist, answers 404."],"params":[{"about":"The job id from Zip files and folders.","example":318,"in":"path","name":"id","required":true}],"path":"/api/v1/drive/zip-jobs/:id","response":"{\n  \"id\": 318,\n  \"state\": \"completed\",\n  \"output_name\": \"drive-export-you-example-com-1793763730.zip\",\n  \"dest_path\": \"Archives/drive-export-you-example-com-1793763730.zip\",\n  \"file_id\": 57\n}\n","status":200,"summary":"Whether a zip has been built and saved, and where it is.","title":"See if a zip is ready","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/bulk/delete' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"file_ids\":[41,42],\"folder_ids\":[7]}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/bulk/delete',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { file_ids => [41, 42], folder_ids => [7] })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-delete-many","method":"POST","notes":["An id that isn't yours counts as not found, the same as one that doesn't exist."],"params":[{"about":"Ids of files to delete.","example":[41,42],"in":"body","name":"file_ids","required":false},{"about":"Ids of folders to delete, with everything inside them.","example":[7],"in":"body","name":"folder_ids","required":false}],"path":"/api/v1/drive/bulk/delete","response":"{\n  \"files\": { \"deleted\": [41, 42], \"not_found\": [] },\n  \"folders\": { \"deleted\": [7], \"not_found\": [] },\n  \"counts\": { \"deleted\": 3, \"not_found\": 0 }\n}\n","status":200,"summary":"Delete a selection of files and folders in one go, for good.","title":"Delete several files and folders at once","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":"homelab-cli drive trash","curl":"curl -s 'https://api.mailmasker.org/api/v1/drive/trash' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/drive/trash',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-trash","method":"GET","notes":[],"params":[],"path":"/api/v1/drive/trash","response":"{\n  \"files\": [\n    { \"id\": 42, \"filename\": \"old-notes.txt\", \"size_bytes\": 1532, \"mime_type\": \"text/plain\", \"deleted_at_display\": \"2026-11-03 21:42:10 CST\" }\n  ],\n  \"folders\": [\n    { \"id\": 7, \"name\": \"Old photos\", \"deleted_at_display\": \"2026-11-02 09:15:00 CST\" }\n  ]\n}\n","status":200,"summary":"Every file and folder you've deleted and can still get back.","title":"List what's in Trash","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":"homelab-cli drive restore 42","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/files/42/restore' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/files/42/restore',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-restore-file","method":"POST","notes":["A file that isn't in your Trash answers 404."],"params":[{"about":"The file's id.","example":42,"in":"path","name":"id","required":true}],"path":"/api/v1/drive/files/:id/restore","response":"{ \"ok\": true }\n","status":200,"summary":"Bring a file back out of Trash, to the folder it was in.","title":"Restore a file from Trash","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"The folder goes back where it was, with all its files and subfolders. Any folders above it that are in Trash come back too.\n","examples":{"cli":"homelab-cli drive restore 7 --folder","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/folders/7/restore' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/folders/7/restore',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-restore-folder","method":"POST","notes":["A folder that isn't in your Trash answers 404."],"params":[{"about":"The folder's id.","example":7,"in":"path","name":"id","required":true}],"path":"/api/v1/drive/folders/:id/restore","response":"{ \"ok\": true }\n","status":200,"summary":"Bring a folder back out of Trash, with everything inside it.","title":"Restore a folder from Trash","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/files/42/purge' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/files/42/purge',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-purge-file","method":"POST","notes":["A file that isn't in your Trash answers 404."],"params":[{"about":"The file's id.","example":42,"in":"path","name":"id","required":true}],"path":"/api/v1/drive/files/:id/purge","response":"{ \"ok\": true }\n","status":200,"summary":"Permanently delete one file that is in Trash.","title":"Delete a file in Trash forever","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"Only for a folder already in Trash. All its files and subfolders go with it, and it can't be undone.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/folders/7/purge' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/folders/7/purge',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-purge-folder","method":"POST","notes":["A folder that isn't in your Trash answers 404."],"params":[{"about":"The folder's id.","example":7,"in":"path","name":"id","required":true}],"path":"/api/v1/drive/folders/:id/purge","response":"{ \"ok\": true }\n","status":200,"summary":"Permanently delete one folder that is in Trash, with everything inside it.","title":"Delete a folder in Trash forever","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/trash/empty' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/trash/empty',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-empty-trash","method":"POST","notes":[],"params":[],"path":"/api/v1/drive/trash/empty","response":"{ \"ok\": true, \"purged\": { \"files\": 3, \"folders\": 1 } }\n","status":200,"summary":"Permanently delete everything in your Trash.","title":"Empty your Trash","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":"homelab-cli drive private 42","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/files/42/private' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/files/42/private',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-file-private","method":"POST","notes":["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."],"params":[{"about":"The file's id.","example":42,"in":"path","name":"id","required":true},{"about":"0 (or false, no, off) to let search find it again. Leave it out, or send 1, to make it private.","example":null,"in":"query","name":"private","required":false}],"path":"/api/v1/drive/files/:id/private","response":"{\n  \"id\": 42,\n  \"name\": \"notes.txt\",\n  \"kind\": \"file\",\n  \"private\": true,\n  \"notice\": \"Marked private. It stops appearing in search on the next index pass; it is not hidden, deleted or unindexed.\"\n}\n","status":200,"summary":"Stop a file turning up in search results, or let it show again.","title":"Keep a file out of search","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":"homelab-cli drive private 7 --folder","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/folders/7/private' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/folders/7/private',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-folder-private","method":"POST","notes":["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."],"params":[{"about":"The folder's id.","example":7,"in":"path","name":"id","required":true},{"about":"0 (or false, no, off) to let search find it again. Leave it out, or send 1, to make it private.","example":null,"in":"query","name":"private","required":false}],"path":"/api/v1/drive/folders/:id/private","response":"{\n  \"id\": 7,\n  \"name\": \"Taxes\",\n  \"kind\": \"folder\",\n  \"private\": true,\n  \"notice\": \"Marked private. It stops appearing in search on the next index pass; it is not hidden, deleted or unindexed.\"\n}\n","status":200,"summary":"Stop everything in a folder turning up in search results, or let it show again.","title":"Keep a folder out of search","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/files/42/share?shared_with=friend%40example.com' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/files/42/share',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    form => { shared_with => 'friend@example.com' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-share-file","method":"POST","notes":["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."],"params":[{"about":"The file's id.","example":42,"in":"path","name":"id","required":true},{"about":"The address of the person to share with. Leave it out for a public link.","example":"friend@example.com","in":"query","name":"shared_with","required":false},{"about":"The link stops working after this many days (1 to 9999). Leave it out for a link that lasts until you revoke it.","example":null,"in":"query","name":"expires_days","required":false}],"path":"/api/v1/drive/files/:id/share","response":"{\n  \"ok\": true,\n  \"id\": 15,\n  \"token\": \"5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e\",\n  \"url\": \"https://drive.example.com/s/5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e\",\n  \"target\": \"file\",\n  \"label\": \"notes.txt\",\n  \"shared_with\": \"friend@example.com\",\n  \"public\": false,\n  \"emailed\": true,\n  \"notice\": \"Shared with friend@example.com and emailed them the link.\"\n}\n","status":201,"summary":"Make a link to one of your files, for one person or for anyone.","title":"Share a file","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/folders/7/share?expires_days=7' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/folders/7/share',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    form => { expires_days => 7 })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-share-folder","method":"POST","notes":["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."],"params":[{"about":"The folder's id.","example":7,"in":"path","name":"id","required":true},{"about":"The address of the person to share with. Leave it out for a public link.","example":null,"in":"query","name":"shared_with","required":false},{"about":"The link stops working after this many days (1 to 9999). Leave it out for a link that lasts until you revoke it.","example":7,"in":"query","name":"expires_days","required":false}],"path":"/api/v1/drive/folders/:id/share","response":"{\n  \"ok\": true,\n  \"id\": 16,\n  \"token\": \"0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e\",\n  \"url\": \"https://drive.example.com/s/0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e\",\n  \"target\": \"folder\",\n  \"label\": \"Photos\",\n  \"shared_with\": null,\n  \"public\": true,\n  \"notice\": \"Public link created. Anyone with the link can open it.\"\n}\n","status":201,"summary":"Make a link to one of your folders, for one person or for anyone.","title":"Share a folder","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/drive/shares' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/drive/shares',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-shares","method":"GET","notes":["Your own list still shows shares whose time has run out; check expires_at."],"params":[],"path":"/api/v1/drive/shares","response":"{\n  \"shared_by_me\": [\n    {\n      \"id\": 16,\n      \"token\": \"0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e\",\n      \"shared_with_email\": null,\n      \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n      \"expires_at\": \"2026-11-10 21:42:10.123456-06\",\n      \"access_count\": 4,\n      \"accessed_at\": \"2026-11-03 22:05:31.402215-06\",\n      \"label\": \"Photos\",\n      \"target\": \"folder\",\n      \"url\": \"https://drive.example.com/s/0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e0ddba11e\"\n    },\n    {\n      \"id\": 15,\n      \"token\": \"5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e\",\n      \"shared_with_email\": \"friend@example.com\",\n      \"created_at\": \"2026-11-03 21:40:02.918377-06\",\n      \"expires_at\": null,\n      \"access_count\": 0,\n      \"accessed_at\": null,\n      \"label\": \"notes.txt\",\n      \"target\": \"file\",\n      \"url\": \"https://drive.example.com/s/5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e\"\n    }\n  ],\n  \"shared_with_me\": [\n    {\n      \"id\": 11,\n      \"token\": \"c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\",\n      \"owner_email\": \"friend@example.com\",\n      \"created_at\": \"2026-11-01 14:03:55.120934-06\",\n      \"expires_at\": null,\n      \"label\": \"Recipes\",\n      \"target\": \"folder\",\n      \"url\": \"https://drive.example.com/s/c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00\"\n    }\n  ]\n}\n","status":200,"summary":"What you have shared, and what others have shared with you, newest first.","title":"List your shares","who":"signed-in"},{"area":"Drive","area_slug":"drive","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/drive/shares/15/revoke' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/drive/shares/15/revoke',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"drive-revoke-share","method":"POST","notes":["A share that isn't yours, or is already revoked, answers 404."],"params":[{"about":"The share's id, from List your shares.","example":15,"in":"path","name":"id","required":true}],"path":"/api/v1/drive/shares/:id/revoke","response":"{ \"ok\": true, \"id\": 15 }\n","status":200,"summary":"Turn off a share's link, for everyone who has it.","title":"Revoke a share","who":"signed-in"}],"area":"Drive","intro":"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.\n","slug":"drive"},{"abilities":[{"area":"Search","area_slug":"search","description":"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.\n","examples":{"cli":"homelab-cli search invoice","curl":"curl -s 'https://api.mailmasker.org/api/v1/search?q=invoice' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/search',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    form => { q => 'invoice' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"search","method":"GET","notes":["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."],"params":[{"about":"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.","example":"invoice","in":"query","name":"q","required":false},{"about":"A name or address. Finds mail from or to them, and photos they are tagged in.","example":null,"in":"query","name":"person","required":false},{"about":"Only items dated on or after this day, as YYYY-MM-DD.","example":null,"in":"query","name":"from","required":false},{"about":"Only items dated on or before this day, as YYYY-MM-DD.","example":null,"in":"query","name":"to","required":false},{"about":"both (the default), mail or drive.","example":null,"in":"query","name":"source","required":false},{"about":"Only mail in this folder, spelled as the results' `mailbox` shows it, such as INBOX.","example":null,"in":"query","name":"mailbox","required":false},{"about":"1 for only mail that has attachments.","example":null,"in":"query","name":"has_attachments","required":false},{"about":"Only files of this type, such as image or application/pdf.","example":null,"in":"query","name":"mime","required":false},{"about":"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.","example":null,"in":"query","name":"type_in","required":false},{"about":"Leave these types out, written the same way as type_in.","example":null,"in":"query","name":"type_ex","required":false},{"about":"Only photos with this tag, or a tag filed under it.","example":null,"in":"query","name":"tag","required":false},{"about":"What to do with files others have shared with you. include (the default), exclude, or only.","example":null,"in":"query","name":"shared","required":false},{"about":"1 to search your deleted files instead.","example":null,"in":"query","name":"in_trash","required":false},{"about":"Only files of at least this many bytes.","example":null,"in":"query","name":"min_size","required":false},{"about":"Only files of at most this many bytes.","example":null,"in":"query","name":"max_size","required":false},{"about":"The most results from mail, and from files. 50 if you leave it out, 200 at most.","example":null,"in":"query","name":"limit","required":false},{"about":"Skip this many results first, to get the next page.","example":null,"in":"query","name":"offset","required":false}],"path":"/api/v1/search","response":"{\n  \"account\": \"you@example.com\",\n  \"indexed\": true,\n  \"filters\": {\n    \"q\": \"invoice\", \"person\": \"\", \"from\": \"\", \"to\": \"\", \"mailbox\": \"\",\n    \"mime\": \"\", \"tag\": \"\", \"shared\": \"include\", \"source\": \"both\",\n    \"type_in\": [], \"type_ex\": [], \"in_trash\": 0, \"has_attachments\": 0,\n    \"limit\": 50, \"offset\": 0\n  },\n  \"mail\": [\n    {\n      \"message_id\": 4182,\n      \"subject\": \"Your November invoice\",\n      \"sent_at\": \"2026-11-03 21:42:10-06\",\n      \"received_at\": \"2026-11-03 21:42:12-06\",\n      \"size_bytes\": 48211,\n      \"has_attachments\": 1,\n      \"is_seen\": 1,\n      \"mailbox\": \"INBOX\",\n      \"rank\": 0.4,\n      \"from_addr\": \"billing@example.com\",\n      \"from_name\": \"Example Billing\",\n      \"snippet\": \"Thanks for your order. Your invoice for November is attached.\"\n    }\n  ],\n  \"drive\": [\n    {\n      \"drive_item_id\": 913,\n      \"drive_file_id\": 2207,\n      \"file_uuid\": \"6f1c2a9e-3b4d-4c5e-9f70-1a2b3c4d5e6f\",\n      \"file_name\": \"invoice-october.pdf\",\n      \"folder_path\": \"Bills\",\n      \"size_bytes\": 88340,\n      \"uploaded_at\": \"2026-11-02 08:15:30.402113-06\",\n      \"content_created_at\": null,\n      \"deleted_at\": null,\n      \"mime_type\": \"application/pdf\",\n      \"rank\": 0.2,\n      \"snippet\": \"Invoice for October. Amount due\"\n    }\n  ]\n}\n","status":200,"summary":"Find email and files by words, people, dates or type, in one search.","title":"Search your mail and files","who":"signed-in"}],"area":"Search","intro":"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.\n","slug":"search"},{"abilities":[{"area":"Invites","area_slug":"invites","description":"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`.\n","examples":{"cli":"homelab-cli invite send --to friend@example.com --message \"Come and join me here.\"","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/invites' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"recipient_email\":\"friend@example.com\",\"message\":\"Come and join me here.\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/invites',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { recipient_email => 'friend@example.com', message => 'Come and join me here.' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"invite-send","method":"POST","notes":["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."],"params":[{"about":"Their email address, where the link is sent.","example":"friend@example.com","in":"body","name":"recipient_email","required":true},{"about":"A note from you, put in the email and shown on the sign-up page.","example":"Come and join me here.","in":"body","name":"message","required":false},{"about":"cli (the default) or web, and the email is sent for you. roundcube_plugin sends no email, leaving the link for you to deliver.","example":null,"in":"body","name":"channel","required":false}],"path":"/api/v1/invites","response":"{\n  \"id\": 12,\n  \"token\": \"f00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeed\",\n  \"sender_email\": \"you@example.com\",\n  \"recipient_email\": \"friend@example.com\",\n  \"channel\": \"cli\",\n  \"message\": \"Come and join me here.\",\n  \"status\": \"pending\",\n  \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"expires_at\": \"2026-11-17 21:42:10.123456-06\",\n  \"accepted_at\": null,\n  \"accepted_user_email\": null,\n  \"revoked_at\": null,\n  \"revoked_by_email\": null,\n  \"url\": \"https://invite.example.com/invite/f00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeed\"\n}\n","status":201,"summary":"Email someone a link they can use to make an account.","title":"Invite someone","who":"signed-in"},{"area":"Invites","area_slug":"invites","description":"`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.\n","examples":{"cli":"homelab-cli invite list","curl":"curl -s 'https://api.mailmasker.org/api/v1/invites' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/invites',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"invite-list","method":"GET","notes":["An invite past its `expires_at` says expired, and no longer counts toward your limit of waiting invites."],"params":[{"about":"true or 1 for everyone's invites, not only yours. Site admins only; anyone else gets 403.","example":null,"in":"query","name":"all","required":false}],"path":"/api/v1/invites","response":"[\n  {\n    \"id\": 12,\n    \"token\": \"f00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeedf00dfeed\",\n    \"sender_email\": \"you@example.com\",\n    \"recipient_email\": \"friend@example.com\",\n    \"channel\": \"cli\",\n    \"message\": \"Come and join me here.\",\n    \"status\": \"pending\",\n    \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"expires_at\": \"2026-11-17 21:42:10.123456-06\",\n    \"accepted_at\": null,\n    \"accepted_user_email\": null,\n    \"revoked_at\": null,\n    \"revoked_by_email\": null\n  }\n]\n","status":200,"summary":"Every invite you have sent, newest first, and what became of each.","title":"List your invites","who":"signed-in"},{"area":"Invites","area_slug":"invites","description":"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.\n","examples":{"cli":"homelab-cli invite revoke 12","curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/invites/12' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/invites/12',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"invite-revoke","method":"DELETE","notes":["An invite that isn't yours, or isn't pending any more, answers 404."],"params":[{"about":"The invite's `id`, from List your invites.","example":12,"in":"path","name":"id","required":true},{"about":"The sender's address, to withdraw an invite someone else sent. Site admins only; anyone else gets 403.","example":null,"in":"query","name":"user","required":false}],"path":"/api/v1/invites/:id","response":"{ \"ok\": true }\n","status":200,"summary":"Cancel a pending invite, so its link stops working.","title":"Withdraw an invite","who":"signed-in"},{"area":"Invites","area_slug":"invites","description":"`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.\n","examples":{"cli":"homelab-cli invite quota show","curl":"curl -s 'https://api.mailmasker.org/api/v1/invites/quota' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/invites/quota',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"invite-quota-mine","method":"GET","notes":[],"params":[],"path":"/api/v1/invites/quota","response":"{ \"max_pending\": 10, \"max_per_day\": 5 }\n","status":200,"summary":"How many invites you may have waiting at once, and send in a day.","title":"See your invite limits","who":"signed-in"},{"area":"Invites","area_slug":"invites","description":"Their own limits if a site admin has set them, otherwise the site's defaults. The answer looks the same either way.\n","examples":{"cli":"homelab-cli invite quota show --user alex@example.com","curl":"curl -s 'https://api.mailmasker.org/api/v1/invites/quota/alex%40example.com' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/invites/quota/alex%40example.com',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"invite-quota-show","method":"GET","notes":[],"params":[{"about":"The sender's address.","example":"alex@example.com","in":"path","name":"sender_email","required":true}],"path":"/api/v1/invites/quota/:sender_email","response":"{ \"max_pending\": 20, \"max_per_day\": 10 }\n","status":200,"summary":"The invite limits that apply to one sender.","title":"See someone's invite limits","who":"site-admin"},{"area":"Invites","area_slug":"invites","description":"Replaces any limits the sender already had. There is no call to remove them and go back to following the defaults.\n","examples":{"cli":"homelab-cli invite quota set --user alex@example.com --max-pending 20 --max-per-day 10","curl":"curl -s -X PUT 'https://api.mailmasker.org/api/v1/invites/quota/alex%40example.com' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"max_pending\":20,\"max_per_day\":10}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->put('https://api.mailmasker.org/api/v1/invites/quota/alex%40example.com',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { max_pending => 20, max_per_day => 10 })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"invite-quota-set","method":"PUT","notes":["Leaving out either number answers 400."],"params":[{"about":"The sender's address.","example":"alex@example.com","in":"path","name":"sender_email","required":true},{"about":"How many unused invites they may have out at once.","example":20,"in":"body","name":"max_pending","required":true},{"about":"How many they may send in any 24 hours.","example":10,"in":"body","name":"max_per_day","required":true}],"path":"/api/v1/invites/quota/:sender_email","response":"{\n  \"sender_email\": \"alex@example.com\",\n  \"max_pending\": 20,\n  \"max_per_day\": 10,\n  \"updated_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"updated_by_email\": \"you@example.com\"\n}\n","status":200,"summary":"Give one sender their own invite limits in place of the site's defaults.","title":"Set someone's invite limits","who":"site-admin"}],"area":"Invites","intro":"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.\n","slug":"invites"},{"abilities":[{"area":"Background jobs","area_slug":"jobs","description":"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.\n","examples":{"cli":"homelab-cli jobs list","curl":"curl -s 'https://api.mailmasker.org/api/v1/jobs' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/jobs',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"job-list","method":"GET","notes":["`started_at` moves on each time a running job makes progress, so it can be later than when the job really began."],"params":[{"about":"1 for everyone's jobs, not only yours. Site admins only; anyone else gets 403.","example":null,"in":"query","name":"all","required":false},{"about":"Only jobs of this kind, such as zip.","example":null,"in":"query","name":"type","required":false},{"about":"Only jobs in this state, such as failed.","example":null,"in":"query","name":"state","required":false}],"path":"/api/v1/jobs","response":"[\n  {\n    \"id\": 42,\n    \"type\": \"zip\",\n    \"state\": \"completed\",\n    \"user_email\": \"you@example.com\",\n    \"output_name\": \"photos.zip\",\n    \"output_size_bytes\": 5242880,\n    \"progress_current\": 2,\n    \"progress_total\": 2,\n    \"error_message\": null,\n    \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"started_at\": \"2026-11-03 21:42:19.004211-06\",\n    \"completed_at\": \"2026-11-03 21:42:31.870442-06\"\n  }\n]\n","status":200,"summary":"Your 50 most recent background jobs, newest first.","title":"List your jobs","who":"signed-in"},{"area":"Background jobs","area_slug":"jobs","description":"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.\n","examples":{"cli":"homelab-cli jobs show 42","curl":"curl -s 'https://api.mailmasker.org/api/v1/jobs/42' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/jobs/42',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"job-show","method":"GET","notes":["A job that isn't yours, or has been cleared away, answers 404."],"params":[{"about":"The job's `id`.","example":42,"in":"path","name":"id","required":true}],"path":"/api/v1/jobs/:id","response":"{\n  \"id\": 42,\n  \"type\": \"zip\",\n  \"state\": \"running\",\n  \"user_email\": \"you@example.com\",\n  \"output_name\": \"photos.zip\",\n  \"output_size_bytes\": null,\n  \"progress_current\": 1,\n  \"progress_total\": 2,\n  \"error_message\": null,\n  \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"started_at\": \"2026-11-03 21:42:15.502114-06\",\n  \"completed_at\": null\n}\n","status":200,"summary":"One job's state and progress, and its file's name and size once it is done.","title":"Check on a job","who":"signed-in"},{"area":"Background jobs","area_slug":"jobs","description":"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.\n","examples":{"cli":"homelab-cli jobs download 42","curl":"curl -s 'https://api.mailmasker.org/api/v1/jobs/42/download' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/jobs/42/download',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"job-download","method":"GET","notes":["A job that hasn't finished yet answers 409.","A job that isn't yours, or has been cleared away, answers 404."],"params":[{"about":"The job's `id`.","example":42,"in":"path","name":"id","required":true}],"path":"/api/v1/jobs/:id/download","response":"(the job's output file)\n","status":200,"summary":"The file a finished job made, such as its zip.","title":"Download a job's file","who":"signed-in"}],"area":"Background jobs","intro":"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.\n","slug":"jobs"},{"abilities":[{"area":"Services","area_slug":"services","description":"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.\n","examples":{"cli":"homelab-cli registry list","curl":"curl -s 'https://api.mailmasker.org/api/v1/registry' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/registry',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"list-services","method":"GET","notes":[],"params":[],"path":"/api/v1/registry","response":"[\n  {\n    \"feature_name\": \"homelab-audit\",\n    \"host\": \"192.0.2.10\",\n    \"port\": 2513,\n    \"health_check_url\": \"/health\",\n    \"description\": \"Audit trail: drains api.audit_queue, serves /api/v1/audit/*\"\n  },\n  {\n    \"feature_name\": \"homelab-drive\",\n    \"host\": \"192.0.2.11\",\n    \"port\": 2501,\n    \"health_check_url\": \"/health\",\n    \"description\": \"File storage web app + JSON API, serves /api/v1/drive/*\"\n  }\n]\n","status":200,"summary":"Every registered service, with its address, port and what it does.","title":"List the services","who":"signed-in"},{"area":"Services","area_slug":"services","description":"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.\n","examples":{"cli":"homelab-cli registry lookup homelab-drive","curl":"curl -s 'https://api.mailmasker.org/api/v1/registry/homelab-drive' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/registry/homelab-drive',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"find-service","method":"GET","notes":["A name no service has registered answers 404."],"params":[{"about":"The service's name, as the list shows it.","example":"homelab-drive","in":"path","name":"feature","required":true}],"path":"/api/v1/registry/:feature","response":"{\n  \"feature_name\": \"homelab-drive\",\n  \"host\": \"192.0.2.11\",\n  \"port\": 2501,\n  \"health_check_url\": \"/health\",\n  \"description\": \"File storage web app + JSON API, serves /api/v1/drive/*\"\n}\n","status":200,"summary":"The address homelab-api sends one service's calls to.","title":"Find a service","who":"signed-in"}],"area":"Services","intro":"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.\n","slug":"services"},{"abilities":[{"area":"Users and roles","area_slug":"users","description":"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.\n","examples":{"cli":"homelab-cli admin users list","curl":"curl -s 'https://api.mailmasker.org/api/v1/admin/users' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/admin/users',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-list-users","method":"GET","notes":["A search comes back in address order. One shorter than 3 characters answers an empty list."],"params":[{"about":"Only accounts whose address starts with this text, ignoring case. At least 3 characters.","example":null,"in":"query","name":"q","required":false},{"about":"With q, the most accounts to return, from 1 to 100. The default is 20.","example":null,"in":"query","name":"limit","required":false}],"path":"/api/v1/admin/users","response":"[\n  {\n    \"id\": 1,\n    \"email\": \"you@example.com\",\n    \"active\": 1,\n    \"created_at\": \"2026-10-12 14:03:51.402117-05\",\n    \"roles\": [\"user\", \"site_admin\"]\n  },\n  {\n    \"id\": 7,\n    \"email\": \"alice@example.com\",\n    \"active\": 1,\n    \"created_at\": \"2026-10-20 09:15:02.551873-05\",\n    \"roles\": [\"user\", \"auditor\"]\n  },\n  {\n    \"id\": 9,\n    \"email\": \"bob@example.com\",\n    \"active\": 0,\n    \"created_at\": \"2026-10-28 18:40:37.004512-05\",\n    \"roles\": [\"user\"]\n  }\n]\n","status":200,"summary":"Every account with its roles and whether it can sign in, or a quick search by address.","title":"List accounts","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"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.\n","examples":{"cli":"homelab-cli admin users create-service-account --email invites@example.com","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/users/service-account' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"email\":\"invites@example.com\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/admin/users/service-account',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { email => 'invites@example.com' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-create-service-account","method":"POST","notes":["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."],"params":[{"about":"The new account's address.","example":"invites@example.com","in":"body","name":"email","required":true},{"about":"A password to use instead of a random one. At least 8 characters.","example":null,"in":"body","name":"password","required":false}],"path":"/api/v1/admin/users/service-account","response":"{ \"id\": 12, \"email\": \"invites@example.com\", \"password\": \"<generated password>\" }\n","status":201,"summary":"Make an account directly, for a system mailbox or an operator or test login.","title":"Create an account without an invite","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/users/7/active' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"active\":false}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/admin/users/7/active',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { active => \\0 })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-suspend-user","method":"POST","notes":["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."],"params":[{"about":"The account's number, from the account list.","example":7,"in":"path","name":"id","required":true},{"about":"false to suspend the account, true to restore it.","example":false,"in":"body","name":"active","required":true}],"path":"/api/v1/admin/users/:id/active","response":"{ \"ok\": true, \"id\": 7, \"email\": \"alice@example.com\", \"active\": false }\n","status":200,"summary":"Stop an account signing in anywhere, or let it sign in again.","title":"Suspend or restore an account","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/admin/mail-usage?user_email=alice%40example.com' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/admin/mail-usage',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    form => { user_email => 'alice@example.com' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-mail-usage","method":"GET","notes":["An address with no account still answers, with null figures and the default limit.","An address without an @ answers 400."],"params":[{"about":"The account's address, exactly as the account has it.","example":"alice@example.com","in":"query","name":"user_email","required":true}],"path":"/api/v1/admin/mail-usage","response":"{\n  \"user_email\": \"alice@example.com\",\n  \"used_bytes\": 734003200,\n  \"messages\": 4182,\n  \"limit_bytes\": 5368709120\n}\n","status":200,"summary":"How much mail one account keeps, and the limit that applies to it.","title":"See an account's mail usage","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/users/7/mail-quota' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"limit_bytes\":5368709120}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/admin/users/7/mail-quota',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { limit_bytes => 5368709120 })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-set-mail-quota","method":"POST","notes":["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."],"params":[{"about":"The account's number, from the account list.","example":7,"in":"path","name":"id","required":true},{"about":"The new limit in bytes, as a whole number. Leave it out, or send null, for the default.","example":5368709120,"in":"body","name":"limit_bytes","required":false}],"path":"/api/v1/admin/users/:id/mail-quota","response":"{ \"ok\": true, \"email\": \"alice@example.com\", \"mail_quota_bytes\": 5368709120 }\n","status":200,"summary":"Change how much mail one account may keep, or put it back to the default.","title":"Set an account's mail quota","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"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).\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/drive/admin/usage?user_email=alice%40example.com' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/drive/admin/usage',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    form => { user_email => 'alice@example.com' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-drive-usage","method":"GET","notes":["An address with no account answers 0 bytes used and the default limit."],"params":[{"about":"The account's address, exactly as the account has it.","example":"alice@example.com","in":"query","name":"user_email","required":true}],"path":"/api/v1/drive/admin/usage","response":"{ \"user_email\": \"alice@example.com\", \"used_bytes\": 2147483648, \"limit_bytes\": 53687091200 }\n","status":200,"summary":"How much one account keeps in drive, and the limit that applies to it.","title":"See an account's drive usage","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X PUT 'https://api.mailmasker.org/api/v1/drive/admin/quota' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"user_email\":\"alice@example.com\",\"limit_bytes\":53687091200}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->put('https://api.mailmasker.org/api/v1/drive/admin/quota',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { user_email => 'alice@example.com', limit_bytes => 53687091200 })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-set-drive-quota","method":"PUT","notes":["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."],"params":[{"about":"The account's address, exactly as the account has it.","example":"alice@example.com","in":"body","name":"user_email","required":true},{"about":"The new limit in bytes, as a whole number. Leave it out, or send null, for the default.","example":53687091200,"in":"body","name":"limit_bytes","required":false}],"path":"/api/v1/drive/admin/quota","response":"{ \"ok\": true, \"user_email\": \"alice@example.com\", \"limit_bytes\": 53687091200 }\n","status":200,"summary":"Change how much one account may keep in drive, or put it back to the default.","title":"Set an account's drive quota","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"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.\n","examples":{"cli":"homelab-cli admin roles list","curl":"curl -s 'https://api.mailmasker.org/api/v1/admin/roles' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/admin/roles',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-list-roles","method":"GET","notes":[],"params":[],"path":"/api/v1/admin/roles","response":"[\n  {\n    \"id\": 1,\n    \"name\": \"user\",\n    \"description\": \"Default role -- standard API access\",\n    \"protected\": 1,\n    \"created_at\": \"2026-10-12 13:58:20.118347-05\",\n    \"permissions\": []\n  },\n  {\n    \"id\": 2,\n    \"name\": \"site_admin\",\n    \"description\": \"Administrative capabilities (informational only; admin routes are hardcoded, not gated by a table, so a bad edit can't lock every admin out)\",\n    \"protected\": 1,\n    \"created_at\": \"2026-10-12 13:58:20.118347-05\",\n    \"permissions\": []\n  },\n  {\n    \"id\": 4,\n    \"name\": \"auditor\",\n    \"description\": \"Reads the audit log\",\n    \"protected\": 0,\n    \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"permissions\": [\"audit.view\"]\n  }\n]\n","status":200,"summary":"Every role, whether it can be deleted, and the permissions it carries.","title":"List roles","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"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.\n","examples":{"cli":"homelab-cli admin users grant-role 7 auditor","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/users/7/roles' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"role\":\"auditor\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/admin/users/7/roles',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { role => 'auditor' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-grant-role","method":"POST","notes":["An unknown account answers 404, and an unknown role 400."],"params":[{"about":"The account's number, from the account list.","example":7,"in":"path","name":"id","required":true},{"about":"The role's name, as the role list shows it.","example":"auditor","in":"body","name":"role","required":true}],"path":"/api/v1/admin/users/:id/roles","response":"{ \"ok\": true }\n","status":200,"summary":"Add a role to an account, and with it everything the role allows.","title":"Give an account a role","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"The account loses what the role allowed, unless another of its roles allows the same. Each removal is recorded in the audit log.\n","examples":{"cli":"homelab-cli admin users revoke-role 7 auditor","curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/admin/users/7/roles/auditor' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/admin/users/7/roles/auditor',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-revoke-role","method":"DELETE","notes":["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."],"params":[{"about":"The account's number, from the account list.","example":7,"in":"path","name":"id","required":true},{"about":"The role's name.","example":"auditor","in":"path","name":"role","required":true}],"path":"/api/v1/admin/users/:id/roles/:role","response":"{ \"ok\": true }\n","status":200,"summary":"Remove one role from an account.","title":"Take a role from an account","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"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.\n","examples":{"cli":"homelab-cli admin roles add auditor --description \"Reads the audit log\"","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/roles' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"name\":\"auditor\",\"description\":\"Reads the audit log\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/admin/roles',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { name => 'auditor', description => 'Reads the audit log' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-create-role","method":"POST","notes":["A name already in use answers 409."],"params":[{"about":"The role's name.","example":"auditor","in":"body","name":"name","required":true},{"about":"What the role is for, in a few words.","example":"Reads the audit log","in":"body","name":"description","required":false}],"path":"/api/v1/admin/roles","response":"{\n  \"id\": 4,\n  \"name\": \"auditor\",\n  \"description\": \"Reads the audit log\",\n  \"protected\": 0,\n  \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"permissions\": []\n}\n","status":201,"summary":"Add a new role, with no permissions yet.","title":"Create a role","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"The role goes completely: every account that had it loses it at once, along with the permissions it carried.\n","examples":{"cli":"homelab-cli admin roles remove auditor","curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/admin/roles/auditor' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/admin/roles/auditor',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-delete-role","method":"DELETE","notes":["The built-in `user`, `site_admin` and `system_agent` roles are protected; deleting one answers 400.","An unknown role answers 404."],"params":[{"about":"The role's name.","example":"auditor","in":"path","name":"name","required":true}],"path":"/api/v1/admin/roles/:name","response":"{ \"ok\": true }\n","status":200,"summary":"Remove a role, taking it from every account that has it.","title":"Delete a role","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"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.\n","examples":{"cli":"homelab-cli admin permissions list","curl":"curl -s 'https://api.mailmasker.org/api/v1/admin/permissions' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/admin/permissions',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-list-permissions","method":"GET","notes":[],"params":[],"path":"/api/v1/admin/permissions","response":"[\n  {\n    \"id\": 1,\n    \"name\": \"audit.view\",\n    \"description\": \"View the system audit log (own entries always allowed; this grants viewing other users' entries too)\"\n  },\n  {\n    \"id\": 3,\n    \"name\": \"domains.catchall\",\n    \"description\": \"Manage domain catch-all routing + domain send-as (the myaccount \\\"Domain catch-all routing\\\" area)\"\n  },\n  {\n    \"id\": 2,\n    \"name\": \"metrics.view\",\n    \"description\": \"View the fleet metrics dashboard (Prometheus-backed) in myaccount\"\n  }\n]\n","status":200,"summary":"Every permission a role can be given, alphabetically.","title":"List permissions","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"Every account with the role gets the permission. Giving a role a permission it already has changes nothing.\n","examples":{"cli":"homelab-cli admin roles grant-permission auditor audit.view","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/roles/auditor/permissions/audit.view' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/admin/roles/auditor/permissions/audit.view',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-grant-permission","method":"POST","notes":["An unknown role or permission answers 404."],"params":[{"about":"The role's name.","example":"auditor","in":"path","name":"role","required":true},{"about":"The permission's name, from the permission list.","example":"audit.view","in":"path","name":"permission","required":true}],"path":"/api/v1/admin/roles/:role/permissions/:permission","response":"{ \"ok\": true }\n","status":200,"summary":"Let every account with a role do one more thing.","title":"Give a role a permission","who":"site-admin"},{"area":"Users and roles","area_slug":"users","description":"Every account with the role loses the permission, unless another of its roles carries it too.\n","examples":{"cli":"homelab-cli admin roles revoke-permission auditor audit.view","curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/admin/roles/auditor/permissions/audit.view' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/admin/roles/auditor/permissions/audit.view',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-revoke-permission","method":"DELETE","notes":["It answers ok even when the role didn't have the permission, or either name is unknown."],"params":[{"about":"The role's name.","example":"auditor","in":"path","name":"role","required":true},{"about":"The permission's name.","example":"audit.view","in":"path","name":"permission","required":true}],"path":"/api/v1/admin/roles/:role/permissions/:permission","response":"{ \"ok\": true }\n","status":200,"summary":"Stop a role carrying one permission.","title":"Take a permission from a role","who":"site-admin"}],"area":"Users and roles","intro":"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.\n","slug":"users"},{"abilities":[{"area":"Domains and DNS","area_slug":"domains","description":"Mail to a domain is accepted only while both `mail_enabled` and `active` are on. `dns_managed` means its DNS zone is kept here.\n","examples":{"cli":"homelab-cli dns domains list","curl":"curl -s 'https://api.mailmasker.org/api/v1/domains' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/domains',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"domains-list","method":"GET","notes":["Yes-or-no fields come back as 1 or 0."],"params":[],"path":"/api/v1/domains","response":"[\n  {\n    \"id\": 1,\n    \"domain_name\": \"example.com\",\n    \"mail_enabled\": 1,\n    \"dns_managed\": 1,\n    \"active\": 1,\n    \"created_by\": \"you@example.com\",\n    \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n  },\n  {\n    \"id\": 2,\n    \"domain_name\": \"example.org\",\n    \"mail_enabled\": 0,\n    \"dns_managed\": 1,\n    \"active\": 1,\n    \"created_by\": \"you@example.com\",\n    \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n  }\n]\n","status":200,"summary":"Every domain on the system, alphabetically, and whether it takes mail.","title":"List domains","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"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.\n","examples":{"cli":"homelab-cli dns domains add example.com","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/domains' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"domain_name\":\"example.com\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/domains',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { domain_name => 'example.com' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"domain-add","method":"POST","notes":["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."],"params":[{"about":"The domain, in lowercase.","example":"example.com","in":"body","name":"domain_name","required":true},{"about":"Accept mail for it. true if left out; false for DNS only.","example":null,"in":"body","name":"mail_enabled","required":false},{"about":"Keep its DNS zone here, creating the zone if needed. true if left out; false for mail only.","example":null,"in":"body","name":"dns_managed","required":false},{"about":"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.","example":null,"in":"body","name":"nameservers","required":false}],"path":"/api/v1/domains","response":"{\n  \"id\": 3,\n  \"domain_name\": \"example.com\",\n  \"mail_enabled\": 1,\n  \"dns_managed\": 1,\n  \"active\": 1,\n  \"created_by\": \"you@example.com\",\n  \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n}\n","status":201,"summary":"Start serving a domain, for mail, DNS or both.","title":"Add a domain","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"Whether the domain takes mail (`mail_enabled` and `active` both on), whether its DNS zone is kept here, and who added it when.\n","examples":{"cli":"homelab-cli dns domains show example.com","curl":"curl -s 'https://api.mailmasker.org/api/v1/domains/example.com' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/domains/example.com',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"domain-show","method":"GET","notes":["A domain that isn't listed answers 404."],"params":[{"about":"The domain name.","example":"example.com","in":"path","name":"domain","required":true}],"path":"/api/v1/domains/:domain","response":"{\n  \"id\": 1,\n  \"domain_name\": \"example.com\",\n  \"mail_enabled\": 1,\n  \"dns_managed\": 1,\n  \"active\": 1,\n  \"created_by\": \"you@example.com\",\n  \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n}\n","status":200,"summary":"One domain, with the same fields as the list of domains.","title":"See a domain's settings","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"Send only what you want to change; the rest stays as it is. The answer is the domain's settings afterwards.\n","examples":{"cli":"homelab-cli dns domains enable example.com","curl":"curl -s -X PATCH 'https://api.mailmasker.org/api/v1/domains/example.com' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"mail_enabled\":true}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->patch('https://api.mailmasker.org/api/v1/domains/example.com',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { mail_enabled => \\1 })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"domain-change","method":"PATCH","notes":["A domain that isn't listed answers 404."],"params":[{"about":"The domain name.","example":"example.com","in":"path","name":"domain","required":true},{"about":"Accept mail for the domain.","example":true,"in":"body","name":"mail_enabled","required":false},{"about":"Whether its DNS zone is kept here. Only the setting changes; no zone is created or removed.","example":null,"in":"body","name":"dns_managed","required":false},{"about":"false turns the whole domain off, as Disable a domain does; true turns it back on.","example":null,"in":"body","name":"active","required":false}],"path":"/api/v1/domains/:domain","response":"{\n  \"id\": 1,\n  \"domain_name\": \"example.com\",\n  \"mail_enabled\": 1,\n  \"dns_managed\": 1,\n  \"active\": 1,\n  \"created_by\": \"you@example.com\",\n  \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"updated_at\": \"2026-11-03 21:50:02.654321-06\"\n}\n","status":200,"summary":"Turn a domain's mail, its DNS management, or the whole domain on or off.","title":"Change a domain's settings","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/example.com' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/example.com',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"domain-disable","method":"DELETE","notes":["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."],"params":[{"about":"The domain name.","example":"example.com","in":"path","name":"domain","required":true}],"path":"/api/v1/domains/:domain","response":"{ \"ok\": true }\n","status":200,"summary":"Stop accepting mail for a domain, without deleting anything.","title":"Disable a domain","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"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.\n","examples":{"cli":"homelab-cli dns records list example.com","curl":"curl -s 'https://api.mailmasker.org/api/v1/domains/example.com/dns/records' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/domains/example.com/dns/records',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dns-records-list","method":"GET","notes":["A domain that isn't listed, or has no DNS zone here, answers 404."],"params":[{"about":"The domain name.","example":"example.com","in":"path","name":"domain","required":true}],"path":"/api/v1/domains/:domain/dns/records","response":"[\n  { \"name\": \"example.com.\", \"type\": \"MX\", \"ttl\": 3600, \"content\": [\"10 mail.example.com.\"] },\n  { \"name\": \"example.com.\", \"type\": \"TXT\", \"ttl\": 3600, \"content\": [\"\\\"v=spf1 mx ~all\\\"\"] },\n  { \"name\": \"www.example.com.\", \"type\": \"A\", \"ttl\": 3600, \"content\": [\"203.0.113.10\"] }\n]\n","status":200,"summary":"Every record in a domain's DNS zone.","title":"List a domain's DNS records","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"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.\n","examples":{"cli":"homelab-cli dns records add example.com --name www.example.com --type A --value 203.0.113.10","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/example.com/dns/records' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"name\":\"www.example.com\",\"type\":\"A\",\"content\":[\"203.0.113.10\"],\"ttl\":3600}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/domains/example.com/dns/records',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { name => 'www.example.com', type => 'A', content => ['203.0.113.10'], ttl => 3600 })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dns-record-set","method":"POST","notes":["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."],"params":[{"about":"The domain whose zone it goes in.","example":"example.com","in":"path","name":"domain","required":true},{"about":"The record's full name, such as `www.example.com`, or the domain itself for its own records. A final dot is optional.","example":"www.example.com","in":"body","name":"name","required":true},{"about":"The record type in capitals, such as A, AAAA, CNAME, MX or TXT.","example":"A","in":"body","name":"type","required":true},{"about":"One value, or a list of them.","example":["203.0.113.10"],"in":"body","name":"content","required":true},{"about":"How long, in seconds, others may keep it. 3600 if left out.","example":3600,"in":"body","name":"ttl","required":false}],"path":"/api/v1/domains/:domain/dns/records","response":"{ \"ok\": true, \"restart_pending\": true }\n","status":201,"summary":"Set every value of one name and type in a domain's DNS zone.","title":"Create or replace a DNS record","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"Deletes the whole record set for that name and type. They go in the body, as when creating a record.\n","examples":{"cli":"homelab-cli dns records delete example.com --name www.example.com --type A","curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/example.com/dns/records' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"name\":\"www.example.com\",\"type\":\"A\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/example.com/dns/records',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { name => 'www.example.com', type => 'A' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dns-record-delete","method":"DELETE","notes":["A domain that isn't listed answers 404."],"params":[{"about":"The domain whose zone it is in.","example":"example.com","in":"path","name":"domain","required":true},{"about":"The record's full name. A final dot is optional.","example":"www.example.com","in":"body","name":"name","required":true},{"about":"The record type in capitals.","example":"A","in":"body","name":"type","required":true}],"path":"/api/v1/domains/:domain/dns/records","response":"{ \"ok\": true, \"restart_pending\": true }\n","status":200,"summary":"Remove every value of one name and type from a domain's DNS zone.","title":"Delete a DNS record","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"`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.\n","examples":{"cli":"homelab-cli dns mail-aliases list","curl":"curl -s 'https://api.mailmasker.org/api/v1/domains/mail-aliases' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/domains/mail-aliases',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"aliases-list","method":"GET","notes":[],"params":[{"about":"Only what is routed to this account.","example":null,"in":"query","name":"destination","required":false}],"path":"/api/v1/domains/mail-aliases","response":"[\n  {\n    \"id\": 4,\n    \"source_pattern\": \"@example.org\",\n    \"destination\": \"you@example.com\",\n    \"active\": 1,\n    \"send_enabled\": 0,\n    \"created_by\": \"you@example.com\",\n    \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n  },\n  {\n    \"id\": 5,\n    \"source_pattern\": \"sales@example.com\",\n    \"destination\": \"you@example.com\",\n    \"active\": 1,\n    \"send_enabled\": 1,\n    \"created_by\": \"you@example.com\",\n    \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n  }\n]\n","status":200,"summary":"Every extra address and whole domain routed to an account, and whether it may send from them.","title":"List extra addresses","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"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.\n","examples":{"cli":"homelab-cli dns mail-aliases add sales@example.com you@example.com","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/mail-aliases' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"source_pattern\":\"sales@example.com\",\"destination\":\"you@example.com\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/domains/mail-aliases',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { source_pattern => 'sales@example.com', destination => 'you@example.com' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"alias-add","method":"POST","notes":["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."],"params":[{"about":"An exact address, or `@` and a domain for the whole domain.","example":"sales@example.com","in":"body","name":"source_pattern","required":true},{"about":"The account it goes to. It isn't checked, so make sure it is a real one.","example":"you@example.com","in":"body","name":"destination","required":true},{"about":"Whether the account may send from it. true if left out.","example":null,"in":"body","name":"send_enabled","required":false}],"path":"/api/v1/domains/mail-aliases","response":"{\n  \"id\": 5,\n  \"source_pattern\": \"sales@example.com\",\n  \"destination\": \"you@example.com\",\n  \"active\": 1,\n  \"send_enabled\": 1,\n  \"created_by\": \"you@example.com\",\n  \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n}\n","status":201,"summary":"Route an address, or a whole domain, to an account, which may also send from it.","title":"Give an account an extra address","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"Only sending changes: mail to the address reaches the account either way, so this can suspend sending without breaking delivery.\n","examples":{"cli":"homelab-cli dns mail-aliases disable-send sales@example.com","curl":"curl -s -X PATCH 'https://api.mailmasker.org/api/v1/domains/mail-aliases/sales%40example.com' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"send_enabled\":false}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->patch('https://api.mailmasker.org/api/v1/domains/mail-aliases/sales%40example.com',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { send_enabled => \\0 })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"alias-sending","method":"PATCH","notes":["An address that isn't on the list answers 404."],"params":[{"about":"The exact address, or `@` and a domain, as the list shows it. The `@` may be sent as it is or as `%40`.","example":"sales@example.com","in":"path","name":"source_pattern","required":true},{"about":"true to allow sending from it, false to stop it.","example":false,"in":"body","name":"send_enabled","required":true}],"path":"/api/v1/domains/mail-aliases/:source_pattern","response":"{\n  \"id\": 5,\n  \"source_pattern\": \"sales@example.com\",\n  \"destination\": \"you@example.com\",\n  \"active\": 1,\n  \"send_enabled\": 0,\n  \"created_by\": \"you@example.com\",\n  \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"updated_at\": \"2026-11-03 21:50:02.654321-06\"\n}\n","status":200,"summary":"Let an account send from an extra address, or stop it, while its mail keeps arriving.","title":"Turn sending on or off for an extra address","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"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.\n","examples":{"cli":"homelab-cli dns mail-aliases remove sales@example.com","curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/mail-aliases/sales%40example.com' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/mail-aliases/sales%40example.com',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"alias-remove","method":"DELETE","notes":["An address that isn't on the list answers 404."],"params":[{"about":"The exact address, or `@` and a domain, as the list shows it. The `@` may be sent as it is or as `%40`.","example":"sales@example.com","in":"path","name":"source_pattern","required":true}],"path":"/api/v1/domains/mail-aliases/:source_pattern","response":"{ \"ok\": true }\n","status":200,"summary":"Stop routing an address or domain to an account, and stop the account sending from it.","title":"Remove an extra address","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"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.\n","examples":{"cli":"homelab-cli dns recipient-access list","curl":"curl -s 'https://api.mailmasker.org/api/v1/domains/recipient-access' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/domains/recipient-access',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"all-blocks-list","method":"GET","notes":[],"params":[{"about":"Only the entries this account made for itself.","example":null,"in":"query","name":"user","required":false}],"path":"/api/v1/domains/recipient-access","response":"[\n  {\n    \"id\": 7,\n    \"recipient\": \"news@example.org\",\n    \"action\": \"REJECT\",\n    \"reason\": \"Spam\",\n    \"created_by\": \"you@example.com\",\n    \"user_email\": null,\n    \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n  },\n  {\n    \"id\": 8,\n    \"recipient\": \"shop@example.com\",\n    \"action\": \"REJECT\",\n    \"reason\": \"blocked via inbox link\",\n    \"created_by\": \"pat@example.com\",\n    \"user_email\": \"pat@example.com\",\n    \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n  }\n]\n","status":200,"summary":"The blocked and allowed addresses of every account and site admin, alphabetically.","title":"List every block and allow entry","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"Takes effect at once, for mail from every sender. Unlike blocking your own addresses, nothing here checks whose address it is.\n","examples":{"cli":"homelab-cli dns recipient-access block shop@example.com --reason \"Sells addresses\"","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/recipient-access' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"recipient\":\"shop@example.com\",\"action\":\"REJECT\",\"reason\":\"Sells addresses\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/domains/recipient-access',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { recipient => 'shop@example.com', action => 'REJECT', reason => 'Sells addresses' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"block-any-address","method":"POST","notes":["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."],"params":[{"about":"The address. Any address, not only one of yours.","example":"shop@example.com","in":"body","name":"recipient","required":true},{"about":"REJECT to block, or OK to allow ahead of every other check. Other Postfix actions, such as DISCARD, are passed on as written.","example":"REJECT","in":"body","name":"action","required":true},{"about":"Why. A blocked sender sees it in the rejection.","example":"Sells addresses","in":"body","name":"reason","required":false}],"path":"/api/v1/domains/recipient-access","response":"{\n  \"id\": 9,\n  \"recipient\": \"shop@example.com\",\n  \"action\": \"REJECT\",\n  \"reason\": \"Sells addresses\",\n  \"created_by\": \"you@example.com\",\n  \"user_email\": null,\n  \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n}\n","status":201,"summary":"Reject all mail to an address, or let it through ahead of every other check.","title":"Block or allow any address","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"Removes the entry for this address, whether a site admin or the account itself made it, so its mail is handled as usual again.\n","examples":{"cli":"homelab-cli dns recipient-access remove shop@example.com","curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/recipient-access/shop%40example.com' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/recipient-access/shop%40example.com',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"unblock-any-address","method":"DELETE","notes":["An address that isn't on the list answers 404."],"params":[{"about":"The address, exactly as the list shows it.","example":"shop@example.com","in":"path","name":"recipient","required":true}],"path":"/api/v1/domains/recipient-access/:recipient","response":"{ \"ok\": true }\n","status":200,"summary":"Take any address off the block-and-allow list, whoever put it there.","title":"Remove a block or allow entry","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"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`.\n","examples":{"cli":"homelab-cli admin block-link domain show example.com","curl":"curl -s 'https://api.mailmasker.org/api/v1/block-link/domains/example.com' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/block-link/domains/example.com',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"domain-block-link-show","method":"GET","notes":["A domain that has never been set answers `enabled` false and `mode` header, with no `updated_at`."],"params":[{"about":"The domain name.","example":"example.com","in":"path","name":"domain","required":true}],"path":"/api/v1/block-link/domains/:domain","response":"{\n  \"domain_name\": \"example.com\",\n  \"enabled\": 1,\n  \"mode\": \"both\",\n  \"updated_by_email\": \"you@example.com\",\n  \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n}\n","status":200,"summary":"Whether accounts on a domain get a \"block this address\" link by default, and where it goes.","title":"See a domain's inbox-link default","who":"site-admin"},{"area":"Domains and DNS","area_slug":"domains","description":"Accounts whose login address is at this domain follow `enabled` unless they have made their own choice; `mode` applies to all of them.\n","examples":{"cli":"homelab-cli admin block-link domain set example.com --enabled --mode both","curl":"curl -s -X PUT 'https://api.mailmasker.org/api/v1/block-link/domains/example.com' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"enabled\":true,\"mode\":\"both\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->put('https://api.mailmasker.org/api/v1/block-link/domains/example.com',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { enabled => \\1, mode => 'both' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"domain-block-link-set","method":"PUT","notes":[],"params":[{"about":"The domain name.","example":"example.com","in":"path","name":"domain","required":true},{"about":"true to add the link by default, false for none.","example":true,"in":"body","name":"enabled","required":true},{"about":"Where the link goes, `header`, `body` or `both`.","example":"both","in":"body","name":"mode","required":true}],"path":"/api/v1/block-link/domains/:domain","response":"{\n  \"domain_name\": \"example.com\",\n  \"enabled\": 1,\n  \"mode\": \"both\",\n  \"updated_by_email\": \"you@example.com\",\n  \"updated_at\": \"2026-11-03 21:42:10.123456-06\"\n}\n","status":200,"summary":"Choose whether accounts on a domain get a \"block this address\" link, and where it goes.","title":"Set a domain's inbox-link default","who":"site-admin"}],"area":"Domains and DNS","intro":"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.\n","slug":"domains"},{"abilities":[{"area":"Catch-all routing","area_slug":"catch-all","description":"`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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/domains/catch-alls' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/domains/catch-alls',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"catch-alls-list","method":"GET","notes":["Yes-or-no fields come back as 1 or 0, and the catch-all's own fields as null when there is none."],"params":[],"path":"/api/v1/domains/catch-alls","response":"[\n  {\n    \"domain\": \"example.com\",\n    \"mail_enabled\": 1,\n    \"active\": 1,\n    \"destination\": null,\n    \"catchall_active\": null,\n    \"send_enabled\": null,\n    \"has_dkim\": 1\n  },\n  {\n    \"domain\": \"example.org\",\n    \"mail_enabled\": 1,\n    \"active\": 1,\n    \"destination\": \"you@example.com\",\n    \"catchall_active\": 1,\n    \"send_enabled\": 0,\n    \"has_dkim\": 0\n  }\n]\n","status":200,"summary":"Every domain on the system, alphabetically, with where its catch-all sends mail.","title":"List every domain's catch-all","who":"permission:domains.catchall"},{"area":"Catch-all routing","area_slug":"catch-all","description":"`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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/domains/example.org/catch-all' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/domains/example.org/catch-all',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"catch-all-show","method":"GET","notes":[],"params":[{"about":"The domain name.","example":"example.org","in":"path","name":"domain","required":true}],"path":"/api/v1/domains/:domain/catch-all","response":"{\n  \"domain\": \"example.org\",\n  \"managed\": true,\n  \"mail_enabled\": 1,\n  \"active\": 1,\n  \"destination\": \"you@example.com\",\n  \"catchall_active\": 1\n}\n","status":200,"summary":"Where one domain's catch-all sends mail, if anywhere.","title":"See a domain's catch-all","who":"permission:domains.catchall"},{"area":"Catch-all routing","area_slug":"catch-all","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X PUT 'https://api.mailmasker.org/api/v1/domains/example.org/catch-all' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"destination\":\"you@example.com\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->put('https://api.mailmasker.org/api/v1/domains/example.org/catch-all',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { destination => 'you@example.com' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"catch-all-set","method":"PUT","notes":["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."],"params":[{"about":"The domain whose mail to catch.","example":"example.org","in":"path","name":"domain","required":true},{"about":"The mailbox it goes to. It must not be at the same domain.","example":"you@example.com","in":"body","name":"destination","required":true},{"about":"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).","example":null,"in":"body","name":"send_enabled","required":false}],"path":"/api/v1/domains/:domain/catch-all","response":"{\n  \"ok\": true,\n  \"domain\": \"example.org\",\n  \"destination\": \"you@example.com\",\n  \"mail_enabled\": true,\n  \"send_enabled\": false\n}\n","status":200,"summary":"Send all mail for a domain to one mailbox.","title":"Set a domain's catch-all","who":"permission:domains.catchall"},{"area":"Catch-all routing","area_slug":"catch-all","description":"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.\n","examples":{"cli":null,"curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/example.org/catch-all' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/example.org/catch-all',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"catch-all-clear","method":"DELETE","notes":["A domain with no catch-all answers 404."],"params":[{"about":"The domain name.","example":"example.org","in":"path","name":"domain","required":true}],"path":"/api/v1/domains/:domain/catch-all","response":"{ \"ok\": true, \"domain\": \"example.org\" }\n","status":200,"summary":"Stop sending a domain's leftover mail to one mailbox.","title":"Remove a domain's catch-all","who":"permission:domains.catchall"},{"area":"Catch-all routing","area_slug":"catch-all","description":"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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/mail/recipient-exists?email=you%40example.com' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/mail/recipient-exists',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    form => { email => 'you@example.com' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"mailbox-exists","method":"GET","notes":["A suspended account, or text without an @, answers false."],"params":[{"about":"The full address to check.","example":"you@example.com","in":"query","name":"email","required":true}],"path":"/api/v1/mail/recipient-exists","response":"{ \"exists\": true }\n","status":200,"summary":"Whether an address is an active account's mailbox.","title":"Check that a mailbox exists","who":"permission:domains.catchall"}],"area":"Catch-all routing","intro":"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.\n","slug":"catch-all"},{"abilities":[{"area":"DKIM signing keys","area_slug":"dkim","description":"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).\n","examples":{"cli":"homelab-cli dns dkim list example.com","curl":"curl -s 'https://api.mailmasker.org/api/v1/domains/example.com/dkim/selectors' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/domains/example.com/dkim/selectors',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dkim-list","method":"GET","notes":["A domain that isn't on the system answers 404."],"params":[{"about":"The domain.","example":"example.com","in":"path","name":"domain","required":true}],"path":"/api/v1/domains/:domain/dkim/selectors","response":"[\n  {\n    \"id\": 9,\n    \"selector\": \"20261103a\",\n    \"state\": \"pending\",\n    \"key_bits\": 2048,\n    \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n    \"activated_at\": null,\n    \"retiring_at\": null,\n    \"retire_after\": null,\n    \"retired_at\": null\n  },\n  {\n    \"id\": 7,\n    \"selector\": \"20261020a\",\n    \"state\": \"active\",\n    \"key_bits\": 2048,\n    \"created_at\": \"2026-10-20 10:02:44.318200-05\",\n    \"activated_at\": \"2026-10-20 14:30:05.771032-05\",\n    \"retiring_at\": null,\n    \"retire_after\": null,\n    \"retired_at\": null\n  }\n]\n","status":200,"summary":"Every DKIM key a domain has had, by selector, newest first, with its state.","title":"List a domain's signing keys","who":"site-admin"},{"area":"DKIM signing keys","area_slug":"dkim","description":"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.\n","examples":{"cli":"homelab-cli dns dkim rotate example.com","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/example.com/dkim/rotate' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/domains/example.com/dkim/rotate',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dkim-rotate","method":"POST","notes":["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."],"params":[{"about":"The domain.","example":"example.com","in":"path","name":"domain","required":true}],"path":"/api/v1/domains/:domain/dkim/rotate","response":"{\n  \"id\": 9,\n  \"domain_id\": 3,\n  \"selector\": \"20261103a\",\n  \"state\": \"pending\",\n  \"public_key\": \"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...IDAQAB\",\n  \"key_bits\": 2048,\n  \"created_by\": \"you@example.com\",\n  \"activated_by\": null,\n  \"retired_by\": null,\n  \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"activated_at\": null,\n  \"retiring_at\": null,\n  \"retire_after\": null,\n  \"retired_at\": null,\n  \"next_action_at\": null\n}\n","status":201,"summary":"Create a new DKIM key for a domain and publish it in DNS, ready to switch to.","title":"Make a new signing key","who":"site-admin"},{"area":"DKIM signing keys","area_slug":"dkim","description":"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.\n","examples":{"cli":"homelab-cli dns dkim activate example.com 20261103a","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/example.com/dkim/20261103a/activate' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/domains/example.com/dkim/20261103a/activate',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dkim-activate","method":"POST","notes":["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."],"params":[{"about":"The domain.","example":"example.com","in":"path","name":"domain","required":true},{"about":"The pending key's selector, from List a domain's signing keys.","example":"20261103a","in":"path","name":"selector","required":true}],"path":"/api/v1/domains/:domain/dkim/:selector/activate","response":"{\n  \"id\": 9,\n  \"domain_id\": 3,\n  \"selector\": \"20261103a\",\n  \"state\": \"active\",\n  \"public_key\": \"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...IDAQAB\",\n  \"key_bits\": 2048,\n  \"created_by\": \"you@example.com\",\n  \"activated_by\": \"you@example.com\",\n  \"retired_by\": null,\n  \"created_at\": \"2026-11-03 21:42:10.123456-06\",\n  \"activated_at\": \"2026-11-04 09:15:27.640118-06\",\n  \"retiring_at\": null,\n  \"retire_after\": null,\n  \"retired_at\": null,\n  \"next_action_at\": null\n}\n","status":200,"summary":"Start signing a domain's mail with a pending key, and start retiring the old one.","title":"Switch to a new signing key","who":"site-admin"},{"area":"DKIM signing keys","area_slug":"dkim","description":"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.\n","examples":{"cli":"homelab-cli dns dkim retire example.com 20261020a","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/domains/example.com/dkim/20261020a/retire' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/domains/example.com/dkim/20261020a/retire',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dkim-retire","method":"POST","notes":["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."],"params":[{"about":"The domain.","example":"example.com","in":"path","name":"domain","required":true},{"about":"The key's selector.","example":"20261020a","in":"path","name":"selector","required":true}],"path":"/api/v1/domains/:domain/dkim/:selector/retire","response":"{ \"ok\": true }\n","status":200,"summary":"Take an active or retiring key out of use and out of DNS straight away.","title":"Retire a signing key now","who":"site-admin"},{"area":"DKIM signing keys","area_slug":"dkim","description":"Removes the key's DNS record and the key itself, and drops it from the list altogether, where a retired key would stay listed.\n","examples":{"cli":null,"curl":"curl -s -X DELETE 'https://api.mailmasker.org/api/v1/domains/example.com/dkim/20261103a' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->delete('https://api.mailmasker.org/api/v1/domains/example.com/dkim/20261103a',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dkim-cancel","method":"DELETE","notes":["Only a pending key can be cancelled; any other answers 409, and retire is the way to take it out of use."],"params":[{"about":"The domain.","example":"example.com","in":"path","name":"domain","required":true},{"about":"The pending key's selector.","example":"20261103a","in":"path","name":"selector","required":true}],"path":"/api/v1/domains/:domain/dkim/:selector","response":"{ \"ok\": true }\n","status":200,"summary":"Throw away a pending key that was never switched to.","title":"Cancel a new signing key","who":"site-admin"}],"area":"DKIM signing keys","intro":"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.\n","slug":"dkim"},{"abilities":[{"area":"DMARC reports","area_slug":"dmarc","description":"`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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/dmarc/status' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/dmarc/status',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dmarc-status","method":"GET","notes":[],"params":[],"path":"/api/v1/dmarc/status","response":"{\n  \"ingest\": {\n    \"last_run\": \"2026-11-03 21:00:04.512339-06\",\n    \"last_host\": \"admin-node-1\",\n    \"last_note\": \"seen=3 reports+=3 records+=7 skipped=0 failed=0\"\n  },\n  \"store\": {\n    \"reports\": 412,\n    \"last_ingest\": \"2026-11-03 21:00:03.998120-06\",\n    \"earliest\": \"2026-11-01 18:00:00-06\",\n    \"latest\": \"2026-11-03 17:59:59-06\"\n  }\n}\n","status":200,"summary":"When reports were last fetched, how that went, and how many are stored.","title":"Check the report fetching","who":"site-admin"},{"area":"DMARC reports","area_slug":"dmarc","description":"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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/dmarc/summary' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/dmarc/summary',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dmarc-summary","method":"GET","notes":[],"params":[{"about":"How many days back to look, by when each report's period began. 30 if you leave it out, 3650 at most.","example":null,"in":"query","name":"days","required":false}],"path":"/api/v1/dmarc/summary","response":"{\n  \"days\": 30,\n  \"domains\": [\n    {\n      \"domain\": \"example.com\",\n      \"policy_p\": \"quarantine\",\n      \"reports\": 58,\n      \"reporters\": 4,\n      \"sources\": 6,\n      \"messages\": 1290,\n      \"aligned\": 1281,\n      \"failing\": 9,\n      \"acted_on\": 9,\n      \"window_start\": \"2026-10-04 19:00:00-05\",\n      \"window_end\": \"2026-11-03 17:59:59-06\"\n    }\n  ]\n}\n","status":200,"summary":"For each domain, how many messages passed DMARC and how many failed.","title":"Summarize DMARC results by domain","who":"site-admin"},{"area":"DMARC reports","area_slug":"dmarc","description":"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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/dmarc/sources' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/dmarc/sources',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dmarc-sources","method":"GET","notes":[],"params":[{"about":"How many days back to look. 30 if you leave it out.","example":null,"in":"query","name":"days","required":false},{"about":"Only this domain.","example":null,"in":"query","name":"domain","required":false},{"about":"1 for only the addresses with some failing mail.","example":null,"in":"query","name":"failing","required":false}],"path":"/api/v1/dmarc/sources","response":"{\n  \"days\": 30,\n  \"domain\": null,\n  \"sources\": [\n    {\n      \"source_ip\": \"203.0.113.77\",\n      \"domain\": \"example.com\",\n      \"messages\": 9,\n      \"aligned\": 0,\n      \"failing\": 9,\n      \"reporters\": 1,\n      \"last_seen\": \"2026-11-02 17:59:59-06\"\n    },\n    {\n      \"source_ip\": \"192.0.2.25\",\n      \"domain\": \"example.com\",\n      \"messages\": 1281,\n      \"aligned\": 1281,\n      \"failing\": 0,\n      \"reporters\": 4,\n      \"last_seen\": \"2026-11-03 17:59:59-06\"\n    }\n  ]\n}\n","status":200,"summary":"Each address the reports saw sending in a domain's name, and how much of its mail passed.","title":"List who sends mail as our domains","who":"site-admin"},{"area":"DMARC reports","area_slug":"dmarc","description":"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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/dmarc/reports' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/dmarc/reports',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dmarc-reports","method":"GET","notes":[],"params":[{"about":"How many days back to look. 30 if you leave it out.","example":null,"in":"query","name":"days","required":false},{"about":"Only reports about this domain.","example":null,"in":"query","name":"domain","required":false},{"about":"At most this many reports. 100 if you leave it out, 1000 at most.","example":null,"in":"query","name":"limit","required":false}],"path":"/api/v1/dmarc/reports","response":"{\n  \"days\": 30,\n  \"reports\": [\n    {\n      \"id\": 412,\n      \"org_name\": \"example.net\",\n      \"report_id\": \"18342296310421557\",\n      \"policy_domain\": \"example.com\",\n      \"policy_p\": \"quarantine\",\n      \"policy_pct\": 100,\n      \"policy_adkim\": \"r\",\n      \"policy_aspf\": \"r\",\n      \"date_begin\": \"2026-11-02 18:00:00-06\",\n      \"date_end\": \"2026-11-03 17:59:59-06\",\n      \"ingested_at\": \"2026-11-03 21:00:03.998120-06\",\n      \"messages\": 214,\n      \"records\": 2\n    }\n  ]\n}\n","status":200,"summary":"The reports received, newest first, with who sent each and the period it covers.","title":"List DMARC reports","who":"site-admin"},{"area":"DMARC reports","area_slug":"dmarc","description":"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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/dmarc/reports/412' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/dmarc/reports/412',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"dmarc-report","method":"GET","notes":["An id with no report answers 404."],"params":[{"about":"The report's `id`, from List DMARC reports.","example":412,"in":"path","name":"id","required":true}],"path":"/api/v1/dmarc/reports/:id","response":"{\n  \"id\": 412,\n  \"org_name\": \"example.net\",\n  \"org_email\": \"dmarc-noreply@example.net\",\n  \"report_id\": \"18342296310421557\",\n  \"date_begin\": \"2026-11-02 18:00:00-06\",\n  \"date_end\": \"2026-11-03 17:59:59-06\",\n  \"policy_domain\": \"example.com\",\n  \"policy_p\": \"quarantine\",\n  \"policy_sp\": null,\n  \"policy_pct\": 100,\n  \"policy_adkim\": \"r\",\n  \"policy_aspf\": \"r\",\n  \"ingested_at\": \"2026-11-03 21:00:03.998120-06\",\n  \"records\": [\n    {\n      \"source_ip\": \"192.0.2.25\",\n      \"message_count\": 212,\n      \"disposition\": \"none\",\n      \"dkim_result\": \"pass\",\n      \"spf_result\": \"pass\",\n      \"header_from\": \"example.com\",\n      \"auth_results\": {\n        \"dkim\": [ { \"domain\": \"example.com\", \"result\": \"pass\", \"selector\": \"20261020a\" } ],\n        \"spf\": [ { \"domain\": \"example.com\", \"result\": \"pass\" } ]\n      }\n    },\n    {\n      \"source_ip\": \"203.0.113.77\",\n      \"message_count\": 2,\n      \"disposition\": \"quarantine\",\n      \"dkim_result\": \"fail\",\n      \"spf_result\": \"fail\",\n      \"header_from\": \"example.com\",\n      \"auth_results\": {\n        \"dkim\": [],\n        \"spf\": [ { \"domain\": \"example.net\", \"result\": \"fail\" } ]\n      }\n    }\n  ]\n}\n","status":200,"summary":"One report in full, with a line for each sending address it covers.","title":"Read a DMARC report","who":"site-admin"}],"area":"DMARC reports","intro":"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.\n","slug":"dmarc"},{"abilities":[{"area":"Fleet and status","area_slug":"fleet-status","description":"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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/admin/ha/status' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/admin/ha/status',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-ha-status","method":"GET","notes":["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."],"params":[],"path":"/api/v1/admin/ha/status","response":"{\n  \"components\": [\n    {\n      \"name\": \"PostgreSQL (Patroni)\",\n      \"kind\": \"patroni\",\n      \"vip\": \"192.0.2.30\",\n      \"leader\": \"prod-homelab06\",\n      \"members\": [\n        { \"name\": \"prod-homelab06\", \"address\": \"192.0.2.6\", \"role\": \"leader\", \"state\": \"running\",\n          \"lag\": null, \"timeline\": 4, \"healthy\": true, \"last_heartbeat\": \"2026-11-03 21:42:10.123456-06\" },\n        { \"name\": \"prod-homelab07\", \"address\": \"192.0.2.7\", \"role\": \"replica\", \"state\": \"streaming\",\n          \"lag\": 0, \"timeline\": 4, \"healthy\": true, \"last_heartbeat\": \"2026-11-03 21:41:52.410028-06\" }\n      ],\n      \"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.\"\n    },\n    {\n      \"name\": \"Connection poolers (pgbouncer)\",\n      \"kind\": \"pgbouncer\",\n      \"members\": [\n        { \"name\": \"prod-homelab09\", \"address\": \"192.0.2.9\", \"role\": \"pooler\", \"state\": \"up\",\n          \"healthy\": true, \"last_heartbeat\": \"2026-11-03 21:41:58.907311-06\" }\n      ],\n      \"note\": \"Every pooler fronts the database VIP, so any one keeps serving across a leader failover. Liveness is the fleet agent heartbeat (:6432 manifest).\"\n    },\n    {\n      \"name\": \"Mailbox servers (dovecot)\",\n      \"kind\": \"dovecot\",\n      \"leader\": \"192.0.2.62\",\n      \"members\": [\n        { \"name\": \"prod-homelabvm62\", \"address\": \"192.0.2.62\", \"role\": \"active\", \"state\": \"up\",\n          \"healthy\": true, \"last_heartbeat\": \"2026-11-03 21:42:01.335871-06\" }\n      ],\n      \"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.\"\n    },\n    {\n      \"name\": \"Internet-facing edges\",\n      \"kind\": \"edges\",\n      \"members\": [\n        { \"name\": \"prod-homelab01\", \"address\": \"192.0.2.1\", \"role\": \"powered on\", \"state\": \"serving\",\n          \"healthy\": true, \"last_heartbeat\": \"2026-11-03 21:42:04.661290-06\" },\n        { \"name\": \"prod-homelab02\", \"address\": \"192.0.2.2\", \"role\": \"off\", \"state\": \"powered off\",\n          \"healthy\": false, \"last_heartbeat\": \"2026-10-30 08:12:44.310925-05\" }\n      ],\n      \"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.\"\n    }\n  ]\n}\n","status":200,"summary":"The database cluster, its connection poolers, the mailbox servers and the internet edges, node by node.","title":"See high-availability status","who":"site-admin"},{"area":"Fleet and status","area_slug":"fleet-status","description":"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.\n","examples":{"cli":"homelab-cli admin dovecot status","curl":"curl -s 'https://api.mailmasker.org/api/v1/admin/dovecot/status' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/admin/dovecot/status',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-dovecot-status","method":"GET","notes":["`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."],"params":[],"path":"/api/v1/admin/dovecot/status","response":"{\n  \"active_address\": \"192.0.2.62\",\n  \"hosts\": [\n    {\n      \"name\": \"prod-homelabvm62\",\n      \"address\": \"192.0.2.62\",\n      \"role\": \"active\",\n      \"healthy\": true,\n      \"last_heartbeat\": \"2026-11-03 21:42:10.123456-06\"\n    },\n    {\n      \"name\": \"prod-homelabvm63\",\n      \"address\": \"192.0.2.63\",\n      \"role\": \"standby\",\n      \"healthy\": true,\n      \"last_heartbeat\": \"2026-11-03 21:41:58.907311-06\"\n    }\n  ],\n  \"paths\": [\n    { \"name\": \"IMAP / IMAPS\", \"ports\": \"143, 993\", \"via\": \"HAProxy active/passive\" },\n    { \"name\": \"LMTP delivery\", \"ports\": \"24\", \"via\": \"HAProxy active/passive\" },\n    { \"name\": \"SASL submission auth\", \"ports\": \"12345\", \"via\": \"HAProxy active/passive\" }\n  ],\n  \"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.\"\n}\n","status":200,"summary":"Which mailbox server is active, which are standing by, and whether each is up.","title":"See the mailbox servers","who":"site-admin"},{"area":"Fleet and status","area_slug":"fleet-status","description":"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.\n","examples":{"cli":"homelab-cli admin fleet status","curl":"curl -s 'https://api.mailmasker.org/api/v1/admin/agent/hosts' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/admin/agent/hosts',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-list-nodes","method":"GET","notes":["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."],"params":[],"path":"/api/v1/admin/agent/hosts","response":"[\n  {\n    \"hostname\": \"prod-homelab12\",\n    \"address\": \"192.0.2.12\",\n    \"agent_port\": 2520,\n    \"agent_version\": \"0.1.9\",\n    \"last_heartbeat\": \"2026-11-03 21:42:10.123456-06\"\n  },\n  {\n    \"hostname\": \"prod-homelab13\",\n    \"address\": \"192.0.2.13\",\n    \"agent_port\": 2520,\n    \"agent_version\": \"0.1.9\",\n    \"last_heartbeat\": \"2026-11-03 21:41:47.880315-06\"\n  }\n]\n","status":200,"summary":"Every node whose agent has ever checked in, and when it last did.","title":"List nodes","who":"site-admin"},{"area":"Fleet and status","area_slug":"fleet-status","description":"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.\n","examples":{"cli":"homelab-cli admin fleet status","curl":"curl -s 'https://api.mailmasker.org/api/v1/admin/agent/status' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/admin/agent/status',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-service-status","method":"GET","notes":["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."],"params":[],"path":"/api/v1/admin/agent/status","response":"[\n  {\n    \"hostname\": \"prod-homelab09\",\n    \"service_name\": \"pgbouncer\",\n    \"package_name\": \"homelab-pgbouncer\",\n    \"kind\": \"pgbouncer\",\n    \"expected\": 1,\n    \"actual\": 1,\n    \"description\": \"PgBouncer connection pooler in front of the shared homelab database\",\n    \"fronts\": null,\n    \"checked_at\": \"2026-11-03 21:41:58.907311-06\"\n  },\n  {\n    \"hostname\": \"prod-homelab03\",\n    \"service_name\": \"webproxy-myaccount.example.com\",\n    \"package_name\": \"homelab-webproxy\",\n    \"kind\": \"http-proxy-vhost\",\n    \"expected\": 1,\n    \"actual\": 1,\n    \"description\": \"HTTPS vhost for myaccount.example.com\",\n    \"fronts\": [\"192.0.2.20:2503\", \"192.0.2.21:2503\"],\n    \"checked_at\": \"2026-11-03 21:42:10.123456-06\"\n  }\n]\n","status":200,"summary":"Every service each node declares, and whether its agent found it running.","title":"List services on every node","who":"site-admin"},{"area":"Fleet and status","area_slug":"fleet-status","description":"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.\n","examples":{"cli":"homelab-cli admin fleet drift","curl":"curl -s 'https://api.mailmasker.org/api/v1/admin/agent/status/mismatches' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/admin/agent/status/mismatches',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-service-problems","method":"GET","notes":["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."],"params":[],"path":"/api/v1/admin/agent/status/mismatches","response":"[\n  {\n    \"hostname\": \"prod-homelab13\",\n    \"service_name\": \"drive\",\n    \"package_name\": \"homelab-drive\",\n    \"kind\": \"http_service\",\n    \"expected\": 1,\n    \"actual\": 0,\n    \"description\": \"File storage web app + JSON API, serves /api/v1/drive/*\",\n    \"fronts\": null,\n    \"checked_at\": \"2026-11-03 21:41:47.880315-06\"\n  }\n]\n","status":200,"summary":"Only the services whose running state doesn't match what their node declares.","title":"List services that aren't running","who":"site-admin"},{"area":"Fleet and status","area_slug":"fleet-status","description":"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.\n","examples":{"cli":"homelab-cli admin fleet topology","curl":"curl -s 'https://api.mailmasker.org/api/v1/admin/agent/topology' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/admin/agent/topology',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-fleet-topology","method":"GET","notes":["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."],"params":[],"path":"/api/v1/admin/agent/topology","response":"{\n  \"heartbeat_stale_after_sec\": 300,\n  \"hosts\": [\n    {\n      \"hostname\": \"prod-homelab42\",\n      \"address\": \"192.0.2.42\",\n      \"agent_version\": \"0.1.9\",\n      \"last_heartbeat\": \"2026-11-03 21:42:10.123456-06\",\n      \"stale\": false,\n      \"monitored\": true,\n      \"services\": [\n        { \"id\": \"prod-homelab42/roundcube-nginx\", \"host\": \"prod-homelab42\", \"service\": \"roundcube-nginx\",\n          \"kind\": \"http_service\", \"package\": \"homelab-roundcube\", \"address\": \"192.0.2.42\",\n          \"expected\": true, \"actual\": true, \"healthy\": true, \"monitored\": true,\n          \"description\": \"Roundcube's internal-only nginx vhost, bridging homelab-webproxy to PHP-FPM\" },\n        { \"id\": \"prod-homelab42/roundcube-php-fpm\", \"host\": \"prod-homelab42\", \"service\": \"roundcube-php-fpm\",\n          \"kind\": \"http_service\", \"package\": \"homelab-roundcube\", \"address\": \"192.0.2.42\",\n          \"expected\": true, \"actual\": true, \"healthy\": true, \"monitored\": true,\n          \"description\": \"PHP-FPM pool serving Roundcube itself\" }\n      ]\n    }\n  ],\n  \"nodes\": [\n    { \"id\": \"prod-homelab42/roundcube-nginx\", \"host\": \"prod-homelab42\", \"service\": \"roundcube-nginx\",\n      \"kind\": \"http_service\", \"package\": \"homelab-roundcube\", \"address\": \"192.0.2.42\",\n      \"expected\": true, \"actual\": true, \"healthy\": true, \"monitored\": true,\n      \"description\": \"Roundcube's internal-only nginx vhost, bridging homelab-webproxy to PHP-FPM\" },\n    { \"id\": \"prod-homelab42/roundcube-php-fpm\", \"host\": \"prod-homelab42\", \"service\": \"roundcube-php-fpm\",\n      \"kind\": \"http_service\", \"package\": \"homelab-roundcube\", \"address\": \"192.0.2.42\",\n      \"expected\": true, \"actual\": true, \"healthy\": true, \"monitored\": true,\n      \"description\": \"PHP-FPM pool serving Roundcube itself\" }\n  ],\n  \"edges\": [\n    { \"from\": \"prod-homelab42/roundcube-nginx\", \"to\": \"prod-homelab42/roundcube-php-fpm\", \"kind\": \"http\" }\n  ]\n}\n","status":200,"summary":"Every machine, the services on it, and which service talks to which, as a graph.","title":"Map the fleet","who":"site-admin"},{"area":"Fleet and status","area_slug":"fleet-status","description":"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.\n","examples":{"cli":"homelab-cli admin agent enroll prod-homelab12","curl":"curl -s -X POST 'https://api.mailmasker.org/api/v1/admin/agent/enroll' \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"hostname\":\"prod-homelab12\"}'","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->post('https://api.mailmasker.org/api/v1/admin/agent/enroll',\n    { Authorization => \"Bearer $ENV{TOKEN}\" },\n    json => { hostname => 'prod-homelab12' })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-enroll-node","method":"POST","notes":["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."],"params":[{"about":"The node's name, exactly as its agent is set up with it.","example":"prod-homelab12","in":"body","name":"hostname","required":true},{"about":"How many minutes the code stays usable. The default is 10.","example":null,"in":"body","name":"ttl_minutes","required":false}],"path":"/api/v1/admin/agent/enroll","response":"{ \"code\": \"<one-time token>\", \"hostname\": \"prod-homelab12\", \"expires_in_minutes\": 10 }\n","status":201,"summary":"Get a one-time code that lets a new node's agent sign itself in.","title":"Enroll a new node","who":"site-admin"},{"area":"Fleet and status","area_slug":"fleet-status","description":"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.\n","examples":{"cli":null,"curl":"curl -s 'https://api.mailmasker.org/api/v1/admin/certstore' \\\n  -H \"Authorization: Bearer $TOKEN\"","perl":"use Mojo::Base -strict;\nuse Mojo::UserAgent;\n\nmy $ua  = Mojo::UserAgent->new;\nmy $res = $ua->get('https://api.mailmasker.org/api/v1/admin/certstore',\n    { Authorization => \"Bearer $ENV{TOKEN}\" })->result;\ndie $res->code, ' ', ($res->json('/error') // $res->message), \"\\n\" unless $res->is_success;\nprint $res->body, \"\\n\";"},"id":"admin-list-certificates","method":"GET","notes":["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."],"params":[],"path":"/api/v1/admin/certstore","response":"[\n  {\n    \"lineage\": \"mail.example.com\",\n    \"serial\": \"04F3A2B19C7D5E6F8091A2B3C4D5E6F70812\",\n    \"not_before\": \"2026-10-15 02:12:44-05\",\n    \"not_after\": \"2027-01-13 01:12:43-06\",\n    \"days_remaining\": 70,\n    \"domains\": [\"mail.example.com\", \"imap.example.com\", \"smtp.example.com\"],\n    \"sha256_fullchain\": \"6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f6b1f\",\n    \"published_by\": \"agent+prod-homelab46@example.com\",\n    \"published_at\": \"2026-10-15 03:12:51.208734-05\",\n    \"promoted_at\": \"2026-10-15 03:12:51.208734-05\"\n  }\n]\n","status":200,"summary":"Each TLS certificate the fleet hands out, soonest to expire first.","title":"List the fleet's certificates","who":"site-admin"}],"area":"Fleet and status","intro":"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.\n","slug":"fleet-status"}],"built":"2026-11-04","site":"https://developer.mailmasker.org","version":"0.1.1"}
