Sign inSign up

liveinaus/msoauth2api

By liveinaus

•Updated 2 days ago

Microsoft OAuth2 mailboxes as HTTP endpoints, with a Vue admin panel. Self-hosted.

Image
0

888

liveinaus/msoauth2api repository overview

⁠msOauth2api

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.

⁠What it does

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⁠.

  • Read the latest message, or a whole folder (inbox and junk)
  • Automatic verification-code extraction
  • Empty the inbox or junk folder
  • Send mail over Outlook SMTP
  • Refresh tokens, individually or in batches
  • Optional AI summarisation via any OpenAI-compatible endpoint
  • Web panel: account list, import/export, mailbox browser, API key management
  • Copy an address from the panel and it watches that mailbox for the code that follows
  • Usage tracking, on copy alone or on mail arriving after the copy (Settings decides which)

⁠Quick start

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.

⁠Configuration

Every variable is documented in env.example⁠. The essentials:

VariableRequiredPurpose
JWT_SECRETyesSigns login tokens. The app refuses to start without it.
ADMIN_USERNAME / ADMIN_PASSWORDnoSeed the admin login on first run (admin / changeme). Ignored afterwards; change credentials in Settings.
MSAPI_DATA_KEYrecommendedEncrypts stored refresh tokens and mail passwords at rest.
PASSWORDnoUpstream's shared secret for the mail endpoints, still accepted verbatim.
SEND_PASSWORDnoUpstream's separate secret for /api/send-mail.
AI_API_KEY / AI_API_URL / AI_MODELnoEnables AI summarisation.
PORT, DB_PATH, TZ, TRUST_PROXY, CORS_ORIGINnoServer tuning. Set TRUST_PROXY=1 behind a reverse proxy so login rate limiting sees real client IPs.
IMAP_TIMEOUT_MS and friendsnoMail fetch timeouts and retries. Defaults suit a normal connection; see Slow mailboxes⁠.

⁠Authentication

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 PASSWORD unset does not make the endpoints public: a container holding a database of mailboxes should never answer an unauthenticated caller.

⁠API

All endpoints accept GET or POST, and read parameters from the query string or the body.

EndpointPurpose
GET/POST /api/mail-newLatest message in a folder
GET/POST /api/mail-allMessages in a folder, newest first
GET/POST /api/refresh-tokenExchange a refresh token for its replacement
GET/POST /api/process-inboxEmpty the inbox
GET/POST /api/process-junkEmpty the junk folder
GET/POST /api/send-mailSend a message over SMTP
POST /api/aiStreaming OpenAI-compatible proxy (SSE)
GET /api/auth/captchaIssues a login captcha: { svg, captchaToken }
POST /api/auth/loginNeeds username, password, captchaToken, captchaAnswer
GET /api/healthUnauthenticated 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.

⁠Parameters
NameEndpointsNotes
refresh_token, client_idall mail endpointsOptional if the address is a stored account
emailall except refresh-tokenThe mailbox to act on
mailboxmail-new, mail-allINBOX or Junk only
response_typemail-newjson (default) or html
limitmail-allDefaults to 100, capped at 1000
shapemail-newarray or object, to pin the response shape (see below)
to, subject, text, htmlsend-mailtext 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"
⁠Response
[
  {
    "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.

⁠Address pool API

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.

EndpointPurpose
GET/POST /api/get-available-emailLeases the next address unused for type
GET/POST /api/get-codeThe code for an address, if one has arrived
POST /api/confirm-emailRetires an address for a type without a code
POST /api/release-emailHands a leased address back early
GET /api/email-statusWhat an address has been used for
GET /api/pool-statusRemaining capacity for a type
⁠Leases

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.

⁠Priority

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.

⁠Typical run
# 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
⁠Parameters
NameEndpointsNotes
typeall except get-code, where optionalThe integration label, e.g. Telegram
emailall except get-available-emailMust be a stored account
from, subjectget-codeCase-insensitive substring filters on sender and subject
sinceget-codeEpoch ms or ISO date. Defaults to the lease time when type given
limitget-codeMessages 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.

⁠Statuses

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
}
⁠One inherited quirk

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.

⁠Slow mailboxes

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:

  • The mailbox will not serve IMAP -- Outlook says so in as many words, it does not clear on a retry, so it answers 502, is recorded against the account, and takes the address out of the pool.
  • The mailbox was merely slow -- nothing is recorded, the address stays in the pool, and 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.

⁠When an account gets marked

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.

  • Only the account's own fault counts. Microsoft rejecting the grant does; a throttled (429), unwell (5xx) or unreachable token endpoint does not, nor does a timeout. Those fail the request and nothing more.
  • It has to repeat. A verdict is only written after 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.

⁠Keeping tokens alive

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.

  • A token never refreshed counts as stale. It holds whatever was imported or consented, of unknown age.
  • Disabled accounts are skipped. Someone switched those off; refreshing one puts it back in circulation as far as Microsoft is concerned.
  • A missed window is caught up, not skipped. A container down at 04:00 and back at 06:00 still sweeps that day. The run is tracked by local calendar date, so the tick cannot fire twice in one night and a restart cannot repeat it.
  • Switching it on part way through a day waits for the next one. Enabling it at noon does not put the whole panel through the token endpoint straight away; the Refresh tokens button is there for that.

Times are local to the container, so set TZ if you want 04:00 to mean 04:00 where you are.

⁠Accounts blocked for abuse

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.

⁠Checking before an address is needed

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 fromtoWhat it covers
3-99-99The bottom of the queue, where trouble collects
141199Everything 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.

⁠Backup and migration

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:

  • JSON, not a copy of the SQLite file. Secrets are encrypted at rest with 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.
  • The file holds refresh tokens and mailbox passwords in the clear. It can read every mailbox on the panel. Treat it like the database.
  • Accounts are matched by address, not by row id, so a merge into a populated panel updates the addresses it knows and adds the rest. Addresses are compared without regard to case, so [email protected] cannot become a second row alongside [email protected].
  • The admin login is only restored when includeAdmin is set. Doing so retires every session on the target, including the one that ran the import.
  • API keys travel as hashes, so existing scripts keep working after a migration without the plain keys ever being written to the file.

⁠Differences from upstream

Behaviour that changed deliberately, beyond the port itself:

  • The account list is ordered by the server. 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.
  • Accounts are stored server-side in SQLite, encrypted at rest, instead of in browser localStorage. Refresh tokens are never sent to the page -- the panel sees a fingerprint.
  • Rolled refresh tokens are persisted. Microsoft invalidates the old token when it issues a new one, so an install that never wrote the replacement back would work once per account and then go stale.
  • Endpoints are never public, as described above.
  • 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.
  • IMAP uses 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.
  • Batch operations are concurrency-limited, because Microsoft throttles the token endpoint. Upstream's browser loop fired one unbounded request per account.
  • Message HTML renders in a sandboxed iframe under a CSP with script-src 'self', so sender markup cannot reach the session token. Remote images are blocked, which also stops tracking pixels.
  • The SMTP ciphers: 'SSLv3' pin is gone; modern OpenSSL refuses that suite outright.
  • Verification-code extraction is implemented. Upstream's README advertised it but the code was not there.
  • The admin login has a captcha and rate limiting. Upstream had no panel login at all: the shared PASSWORD was typed into the page and kept in localStorage.

⁠Development

./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 .

⁠Building the image

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⁠

Tag summary

Content type

Image

Digest

sha256:154d543a6…

Size

93.5 MB

Last updated

2 days ago

docker pull liveinaus/msoauth2api