Microsoft OAuth2 mailboxes as HTTP endpoints, with a Vue admin panel. Self-hosted.
888
Turns Microsoft OAuth2 mailboxes into simple HTTP endpoints, with a Vue web panel for managing accounts and reading mail. Self-hosted as a single Docker container.
Version 0.8.4, MIT licensed. Pin liveinaus/msoauth2api:0.8.4 for a fixed deployment,
or track latest.
Given a Microsoft client_id and a refresh_token, it reads and sends mail without you
touching OAuth. It tries Graph API first and falls back to IMAP when the token was
not granted Mail.Read -- Graph is faster and less rate-limited. Accounts consented only to
the older Outlook IMAP permission can be marked to go straight to IMAP; see
Accounts on the older IMAP grant.
docker run -d --name msoauth2api \
-p 3000:3000 \
-v /docker/msoauth2api-data:/app/data \
-e JWT_SECRET="$(openssl rand -hex 32)" \
-e MSAPI_DATA_KEY="$(openssl rand -hex 32)" \
-e ADMIN_PASSWORD=changeme \
liveinaus/msoauth2api:latest
Open http://localhost:3000 and sign in as admin / changeme. You will be asked to set a
real password before you can continue.
Or with compose (see docker-compose.yml):
JWT_SECRET=$(openssl rand -hex 32) docker compose up -d
Back up
MSAPI_DATA_KEY. It encrypts the stored refresh tokens, and without it they cannot be recovered -- you would have to import every account again.
Every variable is documented in env.example. The essentials:
| Variable | Required | Purpose |
|---|---|---|
JWT_SECRET | yes | Signs login tokens. The app refuses to start without it. |
ADMIN_USERNAME / ADMIN_PASSWORD | no | Seed the admin login on first run (admin / changeme). Ignored afterwards; change credentials in Settings. |
MSAPI_DATA_KEY | recommended | Encrypts stored refresh tokens and mail passwords at rest. |
PASSWORD | no | Upstream's shared secret for the mail endpoints, still accepted verbatim. |
SEND_PASSWORD | no | Upstream's separate secret for /api/send-mail. |
AI_API_KEY / AI_API_URL / AI_MODEL | no | Enables AI summarisation. |
PORT, DB_PATH, TZ, TRUST_PROXY, CORS_ORIGIN | no | Server tuning. Set TRUST_PROXY=1 behind a reverse proxy so login rate limiting sees real client IPs. |
IMAP_TIMEOUT_MS and friends | no | Mail fetch timeouts and retries. Defaults suit a normal connection; see Slow mailboxes. |
The panel uses an admin login: argon2-hashed credentials, JWT sessions, an image captcha and per-IP rate limiting on every password check.
The captcha answer never leaves the server. The browser is given an opaque id and the answer
stays in the process, because signing it into a token for the client to quote back would put
it one atob away (a JWT payload is base64, not ciphertext). Each challenge is also burnt on
a single attempt, win or lose, so one solved captcha cannot cover a run of password guesses.
The mail endpoints are meant for scripts, so they carry no captcha and take an API key created under Settings. Send it either way:
# Preferred: a header, so the secret stays out of logs
curl -H "X-API-Key: msk_..." "http://localhost:3000/api/[email protected]&mailbox=INBOX"
# Upstream-compatible: a query parameter
curl "http://localhost:3000/api/[email protected]&mailbox=INBOX&password=msk_..."
If PASSWORD is set, its value is accepted in the password parameter exactly as upstream,
so existing automation needs no changes.
Unlike upstream, leaving
PASSWORDunset does not make the endpoints public: a container holding a database of mailboxes should never answer an unauthenticated caller.
All endpoints accept GET or POST, and read parameters from the query string or the body.
| Endpoint | Purpose |
|---|---|
GET/POST /api/mail-new | Latest message in a folder |
GET/POST /api/mail-all | Messages in a folder, newest first |
GET/POST /api/refresh-token | Exchange a refresh token for its replacement |
GET/POST /api/process-inbox | Empty the inbox |
GET/POST /api/process-junk | Empty the junk folder |
GET/POST /api/send-mail | Send a message over SMTP |
POST /api/ai | Streaming OpenAI-compatible proxy (SSE) |
GET /api/auth/captcha | Issues a login captcha: { svg, captchaToken } |
POST /api/auth/login | Needs username, password, captchaToken, captchaAnswer |
GET /api/health | Unauthenticated liveness, used by the container healthcheck |
Rate limits, all per IP over 15 minutes: 10 login attempts, 10 credential changes, 60 captcha
requests. Set TRUST_PROXY correctly behind a reverse proxy or every visitor shares one
bucket.
| Name | Endpoints | Notes |
|---|---|---|
refresh_token, client_id | all mail endpoints | Optional if the address is a stored account |
email | all except refresh-token | The mailbox to act on |
mailbox | mail-new, mail-all | INBOX or Junk only |
response_type | mail-new | json (default) or html |
limit | mail-all | Defaults to 100, capped at 1000 |
shape | mail-new | array or object, to pin the response shape (see below) |
to, subject, text, html | send-mail | text or html is required |
Because accounts are stored server-side, you can call an endpoint with just an address and let the server supply the credentials:
curl -H "X-API-Key: msk_..." \
"http://localhost:3000/api/[email protected]&mailbox=INBOX"
[
{
"send": "[email protected]",
"subject": "Your verification code",
"text": "Your code is 483920",
"html": "<p>Your code is <b>483920</b></p>",
"date": "2026-08-13T01:02:03Z",
"code": "483920"
}
]
code is the one field added to upstream's shape, carrying the extracted verification code
when the message has one.
For systems that sign up for a service and then wait for its verification code. Ask for an
address not yet used for a type (Telegram, Discord, anything you like), hand it to
that service, then poll for the code. Types are free text, matched case-insensitively, and
an address used for one type stays available for every other.
All of these need an API key, like the mail endpoints.
| Endpoint | Purpose |
|---|---|
GET/POST /api/get-available-email | Leases the next address unused for type |
GET/POST /api/get-code | The code for an address, if one has arrived |
POST /api/confirm-email | Retires an address for a type without a code |
POST /api/release-email | Hands a leased address back early |
GET /api/email-status | What an address has been used for |
GET /api/pool-status | Remaining capacity for a type |
An address is claimed the moment it is handed out, so two callers cannot be given the same
one, but the claim expires (15 minutes by default, set under Settings). If no code ever
arrives the address returns to the pool, so an abandoned signup does not consume it. Finding
a code confirms the claim permanently, as does confirm-email.
Addresses that are disabled, or whose last token refresh failed, are never handed out: an address that cannot receive mail is worse than none.
Within the pool, addresses are handed out highest priority first, and only then in the usual round-robin. Select rows on the Accounts page and use the priority buttons to raise or lower the selection, or the cross to put it back to normal; a negative priority keeps an address in the pool but at the back of the queue. Priority is per address, not per type, and it does not override the disabled or failed-refresh rules above.
# 1. Take an address for this signup.
curl -H "X-API-Key: msk_..." \
"http://localhost:3000/api/get-available-email?type=Telegram"
# {"email":"[email protected]","type":"telegram","leasedAt":1786…,"leaseExpiresAt":1786…,"remaining":42}
# 2. Sign up with [email protected], then poll. 200 either way; read `status`.
curl -H "X-API-Key: msk_..." \
"http://localhost:3000/api/[email protected]&type=Telegram&from=telegram"
# {"status":"pending","email":"[email protected]","type":"telegram","query":{…}}
# {"status":"found","code":"483920","message":{"from":"[email protected]",
# "subject":"Login code","date":"2026-08-13T12:05:00Z","mailbox":"Junk"}}
# 3. Only if you give up, so the address is not wasted.
curl -X POST -H "X-API-Key: msk_..." -H "Content-Type: application/json" \
-d '{"email":"[email protected]","type":"Telegram"}' \
http://localhost:3000/api/release-email
| Name | Endpoints | Notes |
|---|---|---|
type | all except get-code, where optional | The integration label, e.g. Telegram |
email | all except get-available-email | Must be a stored account |
from, subject | get-code | Case-insensitive substring filters on sender and subject |
since | get-code | Epoch ms or ISO date. Defaults to the lease time when type given |
limit | get-code | Messages scanned per folder, default 10, capped at 50 |
get-code searches the inbox and the junk folder, because verification mail from an unknown
sender is exactly what Outlook files as junk. Pass type wherever you can: it scopes the
search to mail that arrived after the address was leased, which is what stops a previous
run's code being handed back as this run's.
get-code answers 200 with status: "found" or status: "pending", so a poller can tell
"not yet" from a real failure. 404 means the address is not a stored account. Exhausting
the pool gives 409 from get-available-email, with the counts:
{
"error": "No address available for type \"telegram\"",
"type": "telegram",
"available": 0,
"leased": 12,
"confirmed": 88
}
Upstream's mail-new answered with an array of one on the Graph path but a bare
object on the IMAP path. Both are reproduced exactly, by transport, so clients written
against either keep working. Pass shape=array or shape=object to stop depending on which
transport served the request.
Outlook is regularly slow rather than broken, so a read that stumbles is retried before it
is called a failure. The whole read, retries included, runs under one budget
(IMAP_TIMEOUT_MS, default 45s, IMAP_ATTEMPTS goes inside it), and Graph and token calls
have their own (MAIL_HTTP_TIMEOUT_MS, default 30s). Each phase can be tuned separately;
see env.example.
Two outcomes are kept apart, because only one of them is the account's fault:
502, is recorded against the account, and takes the address
out of the pool.get-code answers 200 with status: "pending" and a warning naming the reason, since
a poller should ask again rather than send someone to read the code by hand. The other
mail endpoints answer 503 with Retry-After.Reading the inbox and the junk folder is likewise not all-or-nothing: if one folder answers and the other does not, the messages that were found are still returned.
The warning badge on a row is not a log line: the pool skips any account carrying one, so a marked address is out of circulation until something clears it. Two rules keep it off working accounts.
429), unwell (5xx) or unreachable token endpoint does not, nor does a timeout. Those
fail the request and nothing more.ACCOUNT_FAULT_STREAK consecutive
failures (default 2), and any successful read resets the count. A dead token fails every
time and is marked on the next attempt; a blip never gets its second one.A mailbox that answers also clears a mark left over from an earlier bad minute, so an account that recovers comes back into the pool on its own. Reading mail from the row (the envelope button) or running a refresh is enough to do that by hand.
Microsoft invalidates a refresh token that goes unused for long enough, and a pool whose addresses are handed out unevenly will have some nobody has touched in months. Settings can turn that from a surprise at the moment an address is needed into a scheduled job: set Refresh tokens older than to a number of days and pick a check time, 04:00 by default. Zero days leaves the sweep off, which is the default.
Once a day the panel refreshes every token that has not been refreshed inside that window, oldest first, three at a time -- the same path and the same throttling as the panel's Refresh tokens button, so the marking rules under When an account gets marked apply unchanged.
Times are local to the container, so set TZ if you want 04:00 to mean 04:00 where you are.
A mailbox connected through the callback can be put into service abuse mode within hours of consenting, and nothing tells the panel: the row looks healthy until an address is handed out and the read fails. What Microsoft answers when it happens is
AADSTS70000: User account is found to be in service abuse mode
Any refresh that gets that answer -- the Refresh tokens button, the nightly sweep or the
scheduled check below -- disables the account on the spot, records abuse as the reason
and appends Microsoft's own wording to the account's note, dated. The panel shows Abuse
in place of the usual Disabled badge, with the note on hover.
It does not wait for the second failure an ordinary dead grant needs (see
When an account gets marked). The verdict is specific and
Microsoft never returns it as a blip, so leaving the address in the pool only spends it on
requests that cannot succeed. It is matched on the phrase and not on the error number, since
AADSTS70000 is the generic invalid_grant reply that an ordinary expired token carries too.
Switching an account back on clears the reason with it. block=abuse travels in the
delimited export and the JSON backup, so a blocked row survives a restore.
Settings can re-check accounts on a timer rather than waiting for one to fail in use. Each rule takes a band of the pool and its own interval, so the two ends can be checked at different rates:
| Every (days) | Priority from | to | What it covers |
|---|---|---|---|
| 3 | -99 | -99 | The bottom of the queue, where trouble collects |
| 14 | 11 | 99 | Everything still waiting to be spent |
Pick a check time, 05:00 by default. No rules leaves the check off, which is the default.
Each due account costs one token call, three at a time, on the same path as the refresh button. Which accounts are due is measured from the last refresh, successful or not, so an address the panel spent this morning is not spent again tonight; an account that has never been refreshed is due at once. Bands may overlap -- an account matching several rules is still only checked once -- and disabled rows are skipped. Like the refresh sweep, adding a rule part way through a day waits for the next one.
Settings has a Backup and migration card that exports the whole panel as one JSON document and restores it on another instance: every account with its metadata and usage history, the type configuration, the panel settings, the API keys (as hashes) and the admin login.
Both endpoints take a panel session, so they are reachable from a script with a login token:
# Export
curl -H "Authorization: Bearer $TOKEN" \
https://panel.example.com/api/backup/export -o backup.json
# Restore on another instance (merge is the default; "replace" wipes first)
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"mode\":\"replace\",\"includeAdmin\":true,\"backup\":$(cat backup.json)}" \
https://other.example.com/api/backup/import
Notes:
MSAPI_DATA_KEY, so a file copy is unreadable on an instance holding a different key. The
export decrypts and the import re-encrypts under the target's own key, which means the two
instances need not share one.[email protected] cannot become a second row alongside [email protected].includeAdmin is set. Doing so retires every
session on the target, including the one that ran the import.Behaviour that changed deliberately, beyond the port itself:
GET /api/accounts takes sort and dir,
defaulting to priority/desc -- the order the pool spends addresses in. Sortable:
priority, email, clientId, status, lastRefreshAt, lastUsedAt, id. Rows with
no date sort last either way, id always breaks a tie so paging is stable, and an
unrecognised value falls back to the default rather than erroring. The verification code
and usage columns are not sortable: both come from the usages table, one row per type, so
an address used for three types has no single value to order by.localStorage. Refresh tokens are never sent to the page -- the panel sees a fingerprint.mail-all is bounded (100 by default, 1000 max). Upstream asked Graph for $top=10000
and fetched every IMAP message in the folder, which times out on a large mailbox.imapflow rather than the callback-based node-imap. Upstream's mail-all
could resolve before the last message finished parsing and reply with a partial list.script-src 'self', so
sender markup cannot reach the session token. Remote images are blocked, which also stops
tracking pixels.ciphers: 'SSLv3' pin is gone; modern OpenSSL refuses that suite outright.PASSWORD was typed into the page and kept in localStorage../dev.sh
Starts the backend on :3000 and Vite on :5173, creating backend/.env from
env.example and generating a JWT_SECRET on first run. Vite proxies /api to the
backend.
backend/ Express + TypeScript (strict)
src/db/ SQLite access, at-rest encryption
src/auth/ Admin credentials, API keys
src/middleware/ JWT and API key guards
src/services/ OAuth, Graph, IMAP, SMTP, AI, code extraction
src/routes/ HTTP layer
frontend/ Vue 3 + Vite + vue-router, en/zh i18n
cd backend && npm run build && npm test # tsc + vitest
cd frontend && npm run build # vue-tsc + vite
npx prettier --write .
docker build -t msoauth2api:local .
Three stages: build the SPA, compile the backend and prune to production dependencies, then
assemble a Debian slim runtime. Debian rather than Alpine because better-sqlite3 and
argon2 are native addons that must be built against the libc they run on. The container
runs as the non-root node user, with a healthcheck on /api/health.
Releases publish multi-arch images to Docker Hub and GHCR via [.github/workflows/docker-publish.yml](https://github.com/liveinaus/msOauth2api/blob/main/.github/workf
Content type
Image
Digest
sha256:154d543a6…
Size
93.5 MB
Last updated
2 days ago
docker pull liveinaus/msoauth2api