Private inbound email for LLM agents. Each agent gets a stable mailbox bound to its Ed25519 key. Same key = same mailbox. Different key = different mailbox. 1 EUR/month, 100 MB storage, IMAP + HTTP API. No SMTP. No outbound.
Live at truevipaccess.com. Open source (AGPL v3.0).
Current preferred flow:
- Agent presents
billing_emailplus key proof toPOST /v1/mailboxes/claim. - Service reuses the same mailbox for the same key, or creates a new pending mailbox for a new key.
- Service sends payment link to
billing_email. - After payment, mailbox becomes active for one month.
- Agent presents the same key proof to
POST /v1/access/resolveto obtain IMAP access details.
Legacy flow remains available during migration:
- account creation via
POST /v1/accounts - account token refresh via
POST /v1/auth/refresh - mailbox creation via
POST /v1/mailboxes - IMAP resolve via
POST /v1/imap/resolve
Product scope:
- inbound mailbox access only
- IMAP today
- POP3 / HTTP read API later
- no SMTP submission or outbound sending
Further reading:
- Architecture docs
- Glass Reef story
- Key-bound mailbox spec
- Use cases
- Website copy
- Migration plan
- Future access design
- Hetzner CI/CD
- Hetzner NixOS snapshot builder
- NixOS GitOps on Hetzner
- NixOps migration spec
- NixOps migration plan
- Local workflow validation
- truevipaccess.com deployment
- Cloudflare Tunnel deployment
Deployment runtime template:
- Go
- Hexagonal architecture (
internal/coreports/services + adapter packages) - SQLite (pure Go, no CGO) via
github.com/glebarez/sqlite - GORM ORM
- Goose SQL migrations
- Polar checkout for the preferred key-bound flow
- Stripe Checkout + webhooks kept as legacy fallback
- mock payment links for local development
go run ./cmd/appBuild API service image:
docker build -t mailservice-api:latest -f Dockerfile .Build receive-only mail service image (Postfix + Dovecot + SQLite):
docker build -t mailservice-receive:latest -f docker/mailreceive/Dockerfile .Run receive-only mail service:
docker run --rm -p 25:25 -p 143:143 \
-v "$(pwd)/mailservice.db:/data/mailservice.db" \
-e MAIL_DOMAIN=mail.local \
-e MAILBOX_USER=test \
-e MAILBOX_PASSWORD=secret \
mailservice-receive:latestThe receive container can share the same SQLite DB used by the API (/data/mailservice.db).
API writes mailbox provisioning records into mail_domains and mail_users on payment activation.
One-command local stack from GHCR images:
cp compose.yml.example compose.yml
docker compose pull
docker compose up -d
docker compose logs -f mailreceiveTunnel-based production compose baseline:
cp compose.tunnel.yml.example compose.tunnel.yml
docker compose -f compose.tunnel.yml pull
docker compose -f compose.tunnel.yml up -dThe service auto-loads .env from the project root (via godotenv).
Production delivery:
- production runs on a NixOS host with native API, Postfix, Dovecot, and cloudflared services
- merges to
maintriggerDeploy Production App - CI builds the NixOS system closure first and can push it to Hetzner S3 binary cache when configured
- the deploy workflow syncs the repo to the host and runs
nixos-rebuild switch --flake .#truevipaccess Hetzner OpenTofuremains the manual workflow for infrastructure changes
Live smoke test helper:
./ops/smoke-test-mailbox.sh --billing-email you@example.comThe script:
- checks
/healthz - generates an Ed25519 key pair if needed
- claims a mailbox
- prints the payment URL
- polls
/v1/access/resolveuntil payment activates the mailbox
By default it sends the contents of <work-dir>/identity.pub as the edproof payload,
or <key-path>.pub when a custom key path is used.
The built-in verifier accepts OpenSSH Ed25519 public keys directly and derives the
stable mailbox identity from their SHA-256 fingerprint.
If your verifier expects a different proof blob, pass --edproof or --edproof-file.
On the NixOS production host:
- the API runs as a native systemd service built by Nix
- Postfix and Dovecot handle inbound mail and IMAP as native NixOS services
HTTP_ADDR(default:8080)DATABASE_DSN(defaultmailservice.db)MAX_CONCURRENT_REQUESTS(default100, set0to disable semaphore)BUILD_NUMBER(defaultdev; shown on landing page)CACHE_BUSTER(optional; landing page cache token, defaults toBUILD_NUMBER)PUBLIC_BASE_URL(defaulthttp://localhost:8080)MAIL_DOMAIN(defaultmail.local)IMAP_HOST(defaultMAIL_DOMAIN)IMAP_PORT(default143)MAILBOX_PRICE_CENTS(default299)POLAR_TOKEN(optional; enable Polar for the preferred key-bound flow)POLAR_PRODUCT_ID(required when Polar is enabled)POLAR_SERVER_URL(defaulthttps://api.polar.sh)POLAR_SUCCESS_URL(defaultPUBLIC_BASE_URL/v1/payments/polar/success?checkout_id={CHECKOUT_ID})POLAR_RETURN_URL(defaultPUBLIC_BASE_URL)POLAR_WEBHOOK_SECRET(recommended for production; enables signedPOST /v1/webhooks/polar)STRIPE_CURRENCY(defaultusd)STRIPE_SUCCESS_URL(defaulthttp://localhost:8080/payment/success)STRIPE_CANCEL_URL(defaulthttp://localhost:8080/payment/cancel)STRIPE_SECRET_KEY(optional legacy fallback; if no real provider is configured, mock payment links are used)STRIPE_WEBHOOK_SECRET(required only for real Stripe webhook verification)SENDGRID_API_KEY(optional; enable SendGrid notifier)SENDGRID_FROM_EMAIL(required when SendGrid is enabled)SENDGRID_FROM_NAME(optional, defaultMailService)RESEND_API_KEY(optional; enable Resend notifier)RESEND_FROM_EMAIL(required when Resend is enabled)RESEND_FROM_NAME(optional, defaultMailService)UNSEND_KEY(optional; enable Unsend notifier)UNSEND_BASE_URL(defaulthttps://unsend.admin.lt/api)UNSEND_FROM_EMAIL(required when Unsend is enabled)UNSEND_FROM_NAME(optional, defaultMailService)
Notifier precedence: Unsend -> Resend -> SendGrid -> log notifier.
Preferred key-bound claim flow:
curl -X POST http://localhost:8080/v1/mailboxes/claim \
-H 'Content-Type: application/json' \
-d '{"billing_email":"billing@example.com","edproof":"<proof>"}'Confirm Polar payment after redirect fallback:
curl "http://localhost:8080/v1/payments/polar/success?checkout_id=<polar-checkout-id>"Preferred production payment completion path:
curl -X POST http://localhost:8080/v1/webhooks/polar \
-H 'webhook-id: <message-id>' \
-H 'webhook-timestamp: <unix-seconds>' \
-H 'webhook-signature: v1,<signature>' \
-d '<signed-payload-from-polar>'Resolve IMAP credentials by key proof:
curl -X POST http://localhost:8080/v1/access/resolve \
-H 'Content-Type: application/json' \
-d '{"protocol":"imap","edproof":"<proof>"}'The response includes host, port, username, password, email, and access_token.
Use access_token to call the HTTP read API without a separate account token.
If global concurrency limit is reached, API returns 503 with retry_after_seconds random value in range 3..100.
Legacy account flow:
Create account:
curl -X POST http://localhost:8080/v1/accounts \
-H 'Content-Type: application/json' \
-d '{"owner_email":"owner@example.com"}'Refresh machine credentials:
curl -X POST http://localhost:8080/v1/auth/refresh \
-H 'Content-Type: application/json' \
-d '{"refresh_token":"<refresh-token>"}'Human-only recovery start endpoint:
curl -X POST http://localhost:8080/v1/accounts/recovery/start \
-H 'Content-Type: application/json' \
-d '{"owner_email":"owner@example.com"}'Human recovery complete by URL token (browser friendly):
open "http://localhost:8080/v1/accounts/recovery/complete?token=<one-time-token>"Complete token recovery by POST token:
curl -X POST http://localhost:8080/v1/accounts/recovery/complete \
-H 'Content-Type: application/json' \
-d '{"token":"<one-time-token>"}'List mailboxes:
curl http://localhost:8080/v1/mailboxes \
-H 'X-API-Token: <api-token>'Create mailbox:
curl -X POST http://localhost:8080/v1/mailboxes \
-H 'X-API-Token: <api-token>'Check mailbox status:
curl http://localhost:8080/v1/mailboxes/<mailbox-id> \
-H 'X-API-Token: <api-token>'Resolve IMAP credentials by access token:
curl -X POST http://localhost:8080/v1/imap/resolve \
-H 'Content-Type: application/json' \
-d '{"access_token":"<access-token>"}'Fetch unread messages by access token:
curl -X POST http://localhost:8080/v1/imap/messages \
-H 'Content-Type: application/json' \
-d '{"access_token":"<access-token>","unread_only":true,"limit":20,"include_body":false}'unread_only defaults to true; include_body defaults to false.
Fetch a single message by UID:
curl -X POST http://localhost:8080/v1/imap/messages/get \
-H 'Content-Type: application/json' \
-d '{"access_token":"<access-token>","uid":1,"include_body":true}'For messages/get, include_body defaults to true.
The <access-token> above is the access_token returned by either POST /v1/access/resolve
(preferred key-bound flow) or GET /v1/mailboxes/<id> (legacy account flow). No account-level
X-API-Token is required for these endpoints.
Mock payment (only when Stripe key is not configured):
curl http://localhost:8080/mock/pay/<session-id>- The same key always maps to the same mailbox.
- A different key gets a different mailbox.
billing_emailis only the address used for invoice/payment delivery.- Who actually pays is outside the service model.
This project is licensed under the GNU Affero General Public License v3.0.
See LICENSE for the full text.