Startup Pack fork

Postal with a
provisioning API

Open-source mail server β€” extended by Startup Pack with a full admin REST API, per-user access, member roles, SCIM v2 per-tenant, and auto-provisioned SMTP servers.

This is an unofficial fork. Maintained by Startup Pack, not affiliated with the official Postal project (Krystal Hosting Ltd).

What this fork adds

πŸ”Œ

Admin REST API v2

Full provisioning API β€” orgs, servers, domains, credentials, users. Per-user access with role enforcement.

πŸ‘₯

Multi-user orgs

Multiple users per org with admin, member, readonly roles via API or SCIM.

πŸ”’

Role-based access

Enforced on every API endpoint and across all web UI controllers.

⚑

Auto SMTP server

Creating an org via API or SCIM automatically provisions a Live SMTP server + credential.

πŸ”

SCIM v2 per-tenant

/scim/v2/tenants/:org/Users β€” users and org params managed per tenant.

πŸš€

SSO auto-org

New SSO users get a default org created automatically from their username.

πŸ”“

Multi-provider OIDC

Multiple IdPs (Keycloak, Entra, Google…) β€” one login button per provider, each with its own JWKS cache.

πŸšͺ

BCL & RP logout

Back-Channel Logout invalidates Postal sessions when the IdP logs a user out. RP-Initiated Logout redirects to the IdP end_session_endpoint on sign-out.

Configuration

Add to postal.yml. Postal fetches IdP public keys automatically via JWKS β€” no signing secret stored locally.

Single provider (legacy / simple setup)

api:
  enabled: true
  required_azp: postal-api          # optional β€” restrict to one specific KC client

oidc:
  enabled: true
  name: "Login with Keycloak"
  issuer: https://login.example.com/realms/myrealm  # must match 'iss' in your JWTs
  identifier: postal                # OAuth2 client_id β€” required for the SSO login button
  secret: <client_secret>          # required for the SSO login button; omit if API-only
  # jwks_uri: ...                   # override; auto-discovered from issuer by default
  auto_create_org_on_signup: true
  auto_provision_org: false
  auto_create_user: true            # false = SCIM-only: unknown identities rejected at login (no JIT account creation)

scim:
  enabled: true
  bearer_token: <scim-static-token>

Multiple providers

Use the oidc.providers array to register several IdPs. Each gets its own login button, JWKS cache, and discovery document.

oidc:
  enabled: true
  providers:
    - id: keycloak
      display_name: "Login with Keycloak"
      issuer: https://login.example.com/realms/myrealm
      identifier: postal
      secret: <client_secret>
      scopes: [openid, email]

    - id: entra
      display_name: "Login with Microsoft"
      issuer: https://login.microsoftonline.com/<tenant_id>/v2.0
      identifier: <app_id>
      secret: <app_secret>
      scopes: [openid, email, profile]

    - id: google
      display_name: "Login with Google"
      issuer: https://accounts.google.com
      identifier: <client_id>.apps.googleusercontent.com
      secret: <client_secret>

identifier / secret are only required for the SSO login button (Authorization Code flow). For API JWT verification and BCL, Postal only needs issuer (public JWKS). Omit them if the provider is used for token validation only.

Authentication

Postal is a Resource Server only β€” it never issues tokens. Your IdP (Keycloak, Microsoft Entra, Google) issues JWTs; Postal verifies them via JWKS, auto-discovered from {issuer}/.well-known/openid-configuration and cached 1 h. On key rotation, the cache busts automatically on the next unknown kid.

Authorization: Bearer <access_token>

Token types & access level

TokenHow to get itAccess
super-admin KC client_credentials where azp matches api.required_azp
β€” or any token for a Postal user with admin: true
Everything β€” all orgs, all users, create/delete/suspend orgs
org-admin KC user login (authorization_code or password grant)
User must have admin role in the org
Their orgs: CRUD servers/domains/credentials, manage members, edit org settings
member KC user login β€” member role in org Their orgs: CRUD servers/domains/credentials β€” no org settings, no member management
readonly KC user login β€” readonly role in org Their orgs: GET only on all resources

User identity is resolved from the JWT email or preferred_username claim, matched against User.email_address.

Keycloak β€” service account (super-admin)

Realm β†’ Clients β†’ Create client: set Client authentication ON, Service accounts roles ON.

curl -s -X POST \
  https://login.example.com/realms/myrealm/protocol/openid-connect/token \
  -d grant_type=client_credentials \
  -d client_id=postal-api \
  -d client_secret=<secret> | jq -r .access_token

Keycloak β€” user token (org-admin / member / readonly)

Standard authorization_code flow or, for testing, the password grant (if enabled in realm):

curl -s -X POST \
  https://login.example.com/realms/myrealm/protocol/openid-connect/token \
  -d grant_type=password \
  -d client_id=postal-ui \
  -d username=alice@example.com \
  -d password=<password> | jq -r .access_token

Microsoft Entra

curl -s -X POST \
  https://login.microsoftonline.com/<tenant_id>/oauth2/v2.0/token \
  -d grant_type=client_credentials \
  -d client_id=<app_id> \
  -d client_secret=<secret> \
  -d scope=<app_id>/.default | jq -r .access_token
oidc:
  issuer: https://login.microsoftonline.com/<tenant_id>/v2.0
api:
  required_azp: <app_id>

Google

# Using oauth2l
oauth2l fetch --credentials service-account.json --scope openid email
oidc:
  issuer: https://accounts.google.com
api:
  required_azp: <client_id from service-account.json>
ProviderIssuer (iss)Algorithm
Keycloak{host}/realms/{realm}RS256
Microsoft Entrahttps://login.microsoftonline.com/{tenant}/v2.0RS256
Googlehttps://accounts.google.comRS256

API v2 reference

Base URL: https://postal.example.com/api/v2. All requests need Authorization: Bearer <token> and Content-Type: application/json on writes.

Pagination: ?page=1&per_page=50 (max 200). Errors: {"errors": ["Name can't be blank"]} or {"error": "forbidden"}.

Organizations

GET /api/v2/organizations any valid token

super-admin returns all orgs. org-admin / member / readonly returns only their own orgs.

curl https://postal.example.com/api/v2/organizations \
  -H "Authorization: Bearer $TOKEN"
POST /api/v2/organizations super-admin

Creates an org. Auto-provisions a Live SMTP server + default credential unless skip_default_server: true.

curl -X POST https://postal.example.com/api/v2/organizations \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme",
    "permalink": "acme",
    "time_zone": "UTC",
    "owner_email": "admin@acme.com",
    "skip_default_server": false
  }'
GET /api/v2/organizations/:permalink any valid token

super-admin: any org. Others: only their orgs (404 if not a member).

curl https://postal.example.com/api/v2/organizations/acme \
  -H "Authorization: Bearer $TOKEN"
PATCH /api/v2/organizations/:permalink org-admin
curl -X PATCH https://postal.example.com/api/v2/organizations/acme \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme Corp", "time_zone": "Europe/Paris"}'
DELETE /api/v2/organizations/:permalink super-admin
curl -X DELETE https://postal.example.com/api/v2/organizations/acme \
  -H "Authorization: Bearer $TOKEN"
POST /api/v2/organizations/:permalink/suspend super-admin
curl -X POST https://postal.example.com/api/v2/organizations/acme/suspend \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Payment overdue"}'

# Unsuspend
curl -X POST https://postal.example.com/api/v2/organizations/acme/unsuspend \
  -H "Authorization: Bearer $TOKEN"

Members

GET /api/v2/organizations/:org/members any member of the org
curl https://postal.example.com/api/v2/organizations/acme/members \
  -H "Authorization: Bearer $TOKEN"
POST /api/v2/organizations/:org/members org-admin

user_id accepts UUID or email address. role: admin, member (default), readonly.

curl -X POST https://postal.example.com/api/v2/organizations/acme/members \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"user_id": "auditor@acme.com", "role": "readonly"}'
PATCH /api/v2/organizations/:org/members/:user org-admin
curl -X PATCH https://postal.example.com/api/v2/organizations/acme/members/auditor@acme.com \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "member"}'
DELETE /api/v2/organizations/:org/members/:user org-admin
curl -X DELETE https://postal.example.com/api/v2/organizations/acme/members/auditor@acme.com \
  -H "Authorization: Bearer $TOKEN"

Servers

GET /api/v2/organizations/:org/servers any member of the org
curl https://postal.example.com/api/v2/organizations/acme/servers \
  -H "Authorization: Bearer $TOKEN"
POST /api/v2/organizations/:org/servers member+

mode: Live or Test.

curl -X POST https://postal.example.com/api/v2/organizations/acme/servers \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Production", "permalink": "production", "mode": "Live"}'
PATCH /api/v2/organizations/:org/servers/:server member+
curl -X PATCH https://postal.example.com/api/v2/organizations/acme/servers/production \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"send_limit": 5000, "message_retention_days": 30}'
DELETE /api/v2/organizations/:org/servers/:server member+
curl -X DELETE https://postal.example.com/api/v2/organizations/acme/servers/production \
  -H "Authorization: Bearer $TOKEN"
POST /api/v2/organizations/:org/servers/:server/suspend member+
curl -X POST https://postal.example.com/api/v2/organizations/acme/servers/production/suspend \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"reason": "Abuse detected"}'

curl -X POST .../unsuspend -H "Authorization: Bearer $TOKEN"

Domains

POST /api/v2/organizations/:org/servers/:server/domains member+

Response includes dkim_record_name and dkim_record for DNS setup.

curl -X POST https://postal.example.com/api/v2/organizations/acme/servers/production/domains \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "mail.acme.com"}'
POST /api/v2/.../domains/:uuid/verify member+

Force-marks the domain as verified without checking DNS. Use when ownership was verified out-of-band (e.g., during automated tenant provisioning).

curl -X POST https://postal.example.com/api/v2/organizations/acme/servers/production/domains/<uuid>/verify \
  -H "Authorization: Bearer $TOKEN"
POST /api/v2/.../domains/:uuid/dns_check any member of the org

Re-runs DNS checks (SPF, DKIM, MX, return-path) and returns updated statuses.

curl -X POST https://postal.example.com/api/v2/organizations/acme/servers/production/domains/<uuid>/dns_check \
  -H "Authorization: Bearer $TOKEN"

Credentials

POST /api/v2/organizations/:org/servers/:server/credentials member+

Response includes the generated key β€” only returned on creation, not retrievable later.

curl -X POST https://postal.example.com/api/v2/organizations/acme/servers/production/credentials \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "App mailer", "type": "SMTP"}'

# Response:
# { "id": "...", "name": "App mailer", "type": "SMTP", "key": "smtp+login://...", ... }
GET /api/v2/organizations/:org/servers/:server/credentials any member of the org
curl https://postal.example.com/api/v2/organizations/acme/servers/production/credentials \
  -H "Authorization: Bearer $TOKEN"
DELETE /api/v2/organizations/:org/servers/:server/credentials/:uuid member+
curl -X DELETE https://postal.example.com/api/v2/organizations/acme/servers/production/credentials/<uuid> \
  -H "Authorization: Bearer $TOKEN"

Users

GET /api/v2/users org-admin+

super-admin: all Postal users. org-admin: only members of their orgs.

curl "https://postal.example.com/api/v2/users?page=1&per_page=50" \
  -H "Authorization: Bearer $TOKEN"
POST /api/v2/users org-admin+

admin field is ignored unless the caller is super-admin.

curl -X POST https://postal.example.com/api/v2/users \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Alice",
    "last_name": "Smith",
    "email_address": "alice@acme.com",
    "time_zone": "UTC",
    "password": "hunter2"
  }'
PATCH /api/v2/users/:id org-admin+

:id accepts UUID or email. org-admin can only update users in their orgs and cannot set admin.

curl -X PATCH https://postal.example.com/api/v2/users/alice@acme.com \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"first_name": "Alicia"}'
DELETE /api/v2/users/:id super-admin

Permanently deletes the Postal user. To remove from an org without deleting, use the members endpoint instead.

curl -X DELETE https://postal.example.com/api/v2/users/alice@acme.com \
  -H "Authorization: Bearer $TOKEN"

SCIM v2 β€” per-tenant

All SCIM endpoints are namespaced per org: /scim/v2/tenants/:org_permalink/…. Auth: static Authorization: Bearer <scim.bearer_token>.

Users

MethodPathDescription
GET/scim/v2/tenants/:org/UsersList org members as SCIM users
POST/scim/v2/tenants/:org/UsersCreate user (if new) + add to org with optional role
GET/scim/v2/tenants/:org/Users/:idGet user (scoped to org)
PUT / PATCH/scim/v2/tenants/:org/Users/:idUpdate user attrs + optional role change via Operations
DELETE/scim/v2/tenants/:org/Users/:idRemove from org; delete user globally if no other orgs
# Create user in org with member role
curl -X POST https://postal.example.com/scim/v2/tenants/acme/Users \
  -H "Authorization: Bearer $SCIM_TOKEN" \
  -H "Content-Type: application/scim+json" \
  -d '{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
    "userName": "bob@acme.com",
    "name": { "givenName": "Bob", "familyName": "Jones" },
    "emails": [{ "value": "bob@acme.com", "primary": true }],
    "role": "member"
  }'

Groups (= the org)

In this model, one org = one SCIM group. The group is the org β€” there are no sub-groups or teams within an org. Postal has no sub-group concept: roles (admin, member, readonly) are set directly on the user↔org link. If you need separate access scopes, create one org per scope and provision users into each.

No POST or DELETE β€” org lifecycle is managed via API v2. PATCH handles org attributes and members in one request.

MethodPathDescription
GET/scim/v2/tenants/:org/GroupsReturns the org as a SCIM group
GET/scim/v2/tenants/:org/Groups/:idGet the org group
PUT / PATCH/scim/v2/tenants/:org/Groups/:idUpdate org name, time_zone, permalink + manage members
# Update org name + add a member + change a role in one request
curl -X PATCH https://postal.example.com/scim/v2/tenants/acme/Groups/42 \
  -H "Authorization: Bearer $SCIM_TOKEN" \
  -H "Content-Type: application/scim+json" \
  -d '{
    "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
    "displayName": "Acme Corporation",
    "urn:postal:1.0:org": { "time_zone": "Europe/Paris" },
    "Operations": [
      { "op": "add",    "path": "members", "value": [{ "value": "alice@acme.com", "role": "admin" }] },
      { "op": "remove", "path": "members", "value": [{ "value": "old@acme.com" }] }
    ]
  }'

urn:postal:1.0:org extension fields

FieldTypeDescription
permalinkstringOrg slug (URL identifier)
time_zonestringRails time zone name (e.g. Europe/Paris)
statusstringRead-only: active or suspended

Member roles

Permission super-admin admin member readonly
Create / delete orgsβœ“βœ—βœ—βœ—
Suspend / unsuspend orgsβœ“βœ—βœ—βœ—
Edit org settingsβœ“βœ“βœ—βœ—
Manage members (add/remove/role)βœ“βœ“βœ—βœ—
Create / delete / suspend serversβœ“βœ“βœ“βœ—
Create / delete domains & credentialsβœ“βœ“βœ“βœ—
Manage routes, webhooks, endpointsβœ“βœ“βœ“βœ—
View messages, logs, statsβœ“βœ“βœ“βœ“
List / update users (scoped to org)βœ“βœ“βœ—βœ—
Delete users globallyβœ“βœ—βœ—βœ—

Roles are enforced on both the API v2 and the web UI. readonly users see everything but cannot write.

SSO / OIDC provisioning

Disable local (password) login

Set oidc.local_authentication_enabled: false to force SSO-only access. When disabled:

  • The email/password form is hidden from the login page
  • POST /login, password reset requests and password reset flows are all blocked server-side (403)
  • Only the Login with {provider} button remains
  • Users with no SSO identity can no longer log in β€” ensure all users are in your IdP before enabling this
oidc:
  enabled: true
  issuer: https://login.example.com/realms/myrealm
  local_authentication_enabled: false   # SSO only, no password login

JIT user provisioning

When a user logs in via SSO and does not exist in Postal, they are automatically created (JIT provisioning). Name and email are read from the JWT claims (given_name, family_name, email).

Auto-create org on signup

Enable oidc.auto_create_org_on_signup: true. When a brand-new user is JIT-provisioned, Postal automatically creates an org named after their preferred_username (or email prefix if absent). The slug is parameterized and deduplicated if taken.

oidc:
  auto_create_org_on_signup: true

# alice.smith@example.com logs in for the first time
# β†’ user created
# β†’ org "alice-smith" created (Live SMTP server + default credential auto-provisioned)
# β†’ alice is org admin

Org provisioning from JWT claim

Enable oidc.auto_provision_org: true. On every login, Postal reads the organization claim and creates/joins the org:

# JWT claim β€” Keycloak can send a custom claim:
"organization": { "acme": { "name": "Acme Corp" } }
# or as array:
"organization": ["acme", "beta-corp"]

Both options can be active at the same time: auto-create on first login + sync from claim on every login.

Back-Channel Logout (BCL)

When a user logs out of the IdP (or is force-logged-out), the IdP POSTs a signed logout_token to Postal. Postal validates the RS256 signature against the provider's JWKS, then invalidates all active Postal sessions for that user.

Endpoint: POST /auth/oidc/backchannel_logout (no authentication header required β€” the logout_token itself is the proof).

Configure in Keycloak: Realm β†’ Clients β†’ your client β†’ Advanced β†’ Backchannel logout URL:

https://postal.example.com/auth/oidc/backchannel_logout

Enable Backchannel logout session required so Keycloak always includes sid and sub in the logout_token.

With multiple providers, the single endpoint handles all of them β€” Postal identifies the provider from the iss claim and picks the right JWKS.

RP-Initiated Logout (redirect logout)

When a user clicks Sign out in Postal, if a Keycloak id_token is present in the session, Postal redirects to the IdP's end_session_endpoint with:

  • id_token_hint β€” the user's original id_token
  • post_logout_redirect_uri β€” Postal's login page
  • state β€” random nonce for CSRF protection

The IdP then terminates the SSO session and redirects back to the Postal login page. If the provider does not expose an end_session_endpoint in its discovery document, Postal falls back to a local session invalidation only.

Configure in Keycloak: Realm β†’ Clients β†’ your client β†’ Advanced β†’ Valid post logout redirect URIs:

https://postal.example.com/login

Deployment

Docker images

CI publishes on every green push to main:

RegistryImageNotes
GitHub Container Registryghcr.io/startuppack/postal:latestPrivate org package β€” requires read:packages token
Internal Harborpublic-harbor.gottaphish.com/startuppack/postal:latestUsed by the k3s cluster; pull secret harbor-startuppack-pull

Both are multi-arch (linux/amd64 + linux/arm64).

k3s manifests

Kubernetes manifests live in infra/k3s/postal/ of the infra repo:

FilePurpose
00-deps.yamlRabbitMQ (required for worker queues)
10-init-job.yamlOne-shot postal initialize β€” creates DB schema on first deploy
20-workloads.yamlDeployments: postal-web, postal-smtp, postal-worker + Services + IngressRoute

Configuration is injected via a single k8s Secret postal-config mounted at /opt/postal/config/postal.yml.

PostgreSQL (postgres-global)

This fork runs on a CloudNativePG cluster instead of the default MariaDB. Two databases are required:

DatabasePurpose
postalMain Rails app DB (main_db)
postal_messagesMessage storage β€” per-server schemas created automatically (message_db)
main_db:
  adapter: postgresql
  host: postgres-global-rw.postgres-global.svc.cluster.local
  port: 5432
  database: postal
  username: postal
  password: "<from-secret>"

message_db:
  adapter: postgresql
  host: postgres-global-rw.postgres-global.svc.cluster.local
  port: 5432
  database: postal_messages
  username: postal
  password: "<from-secret>"

Run postal initialize (via the init job) once after creating the databases to load the schema.

Initial bootstrap (Rails runner)

After the init job completes, create the admin user, org, server, and SMTP credential:

kubectl exec -n postal deploy/postal-worker -- postal runner "
u = User.where(email_address: 'admin@example.com').first_or_initialize
u.first_name = 'Admin'; u.last_name = 'SP'; u.admin = true
u.password = 'changeme'; u.password_confirmation = 'changeme'; u.save!
org = Organization.where(permalink: 'startuppack').first ||
      Organization.create!(name: 'Startuppack', permalink: 'startuppack', owner: u)
srv = org.servers.where(permalink: 'tenants').first ||
      org.servers.create!(name: 'tenants', permalink: 'tenants', mode: 'Live')
c = srv.credentials.where(name: 'shared-smtp').first ||
    srv.credentials.create!(type: 'SMTP', name: 'shared-smtp')
puts c.key"

Keycloak service account

Create a service account client in the Keycloak global realm for API v2 super-admin access:

# Client ID: postal-api
# Service accounts enabled: true
# Authentication flow: client_credentials

# Test token acquisition:
curl -s https://login.example.com/realms/global/protocol/openid-connect/token \
  -d "grant_type=client_credentials&client_id=postal-api&client_secret=<secret>" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])"

# Test API access:
curl https://postal.example.com/api/v2/organizations \
  -H "Authorization: Bearer $TOKEN"

Wire the client ID and secret to the onboarding platform via k8s Secret selfhosted-postal-env:

SELFHOSTED_POSTAL_API_CLIENT_ID=postal-api
SELFHOSTED_POSTAL_API_CLIENT_SECRET=<client-secret>
SELFHOSTED_POSTAL_KC_TOKEN_URL=https://login.example.com/realms/global/protocol/openid-connect/token
SELFHOSTED_POSTAL_SMTP_PASS=<smtp-bootstrap-credential-key>