Skip to content

Postal by Startup Pack — differences from upstream #1

Description

@thib-d

This issue tracks all divergences between this fork and postalserver/postal. It is kept as a living document.

Upstream base: postalserver/postal main at d038eaa (v3.3.7, 2026-06-03)


Upstream PRs merged into this fork

PR Title Category
#3050 Fix invalid YAML syntax in config docs docs / bug
#3130 PKCE support for OIDC feat ✅ adapted (optional per-provider)
#3290 Add message headers to webhook event payload feat
#3488 Message-ID idempotency support in send API feat
#3512 Add X-Envelope-From header to incoming messages fix
#3546 Scheduled task to unlock stale webhook request locks fix
#3568 Prevent nil description 500 in routes view bug
#3570 Bound outbound delivery timeouts fix
#3574 Non-interactive make-user via POSTAL_INITIAL_USER_* env vars feat
#3594 Encode subject to UTF-8 before database insertion bug

Upstream PRs evaluated but not merged

PR Title Reason skipped
#3030 / #3029 DKIM changes Needs more testing
#3031 Dequeue size config Low priority
#3036 / #2855 Bounce token improvements Not blocking
#3074 Config hint improvements Minor UX
#3221 Login attempt logging Nice-to-have
#3399 Subject search Low priority
#3400 Listmonk / bounce type Niche feature
#3446 Server priority field Schema change, deferred
#3467 gem.coop source Infrastructure
#3481 OIDC auto-create users Superseded by our own JIT provisioning
#3516 ARM64 Docker image Not needed (amd64-only cluster)
#3517 Raw message retention config Deferred
#3541 JSON structured logging Planned
#3552 / #3554 / #3557 / #3576 / #3580 / #3582 / #3587 / #3588 / #3589 Dependabot bumps To be handled via bundle update on next release
#3555 SMTP relay auth Planned
#3571 Send limit priority Deferred

Fork-specific features

Admin REST API v2

Full CRUD REST API mounted at /api/v2/ (JWT Bearer auth via Keycloak service account):

Endpoint Description
GET/POST /api/v2/organizations List / create organizations
GET/DELETE /api/v2/organizations/:slug Get / delete an organization
POST/DELETE /api/v2/organizations/:slug/members Add / remove members
GET/POST /api/v2/users List / create users
GET /api/v2/users/:email Get user by email

Auth: Keycloak service account (client_credentials) issues JWT; Postal validates azp claim → super-admin access. Config (api.enabled is required — routes are only registered when it is true):

api:
  enabled: true
  required_azp: <your-service-account-client-id>

PostgreSQL support (main_db + message_db)

Both databases can be switched to PostgreSQL via config:

main_db:
  adapter: postgresql       # or mysql2 (default)
  host: <pg-primary-host>
  port: 5432
  # Optional read replica:
  ro_host: <pg-replica-host>

message_db:
  adapter: postgresql
  database: postal_messages  # host DB; per-server schemas inside
  • main_db: Rails multi-DB with RW primary + optional RO replica (connects_to)
  • message_db: per-server PG schemas ("postal-server-{id}") instead of MySQL databases
  • Provisioner: dual MySQL/PG path; handles composite primary keys and prefix-length hints correctly
  • database.yml rewritten: plain ERB interpolation, no YAML anchors/merge-keys (Psych 5 compat), no -%> (ERB 6/Prism compat)

Multi-provider OIDC with optional PKCE (adapted from postalserver#3130)

Multiple OIDC providers configurable under oidc.providers[] (array). Each provider can have independent settings. PKCE is optional per provider:

oidc:
  providers:
    - id: keycloak
      issuer: https://your-keycloak/realms/your-realm
      identifier: postal
      secret: "..."
      pkce: true          # optional, default false

Single-provider flat config (oidc.issuer, oidc.pkce) still supported for backwards compat.

Keycloak OIDC + SCIM

  • SSO via Keycloak (postal client, confidential)
  • Back-Channel Logout: POST /auth/oidc/backchannel_logout — invalidates all active sessions for the user
  • RP-Initiated Logout: redirects to IdP end_session_endpoint on sign-out
  • auto_provision_org: true — JIT org creation on first SSO login
  • Keycloak service account: issues client_credentials JWT for API v2 super-admin access

Image registry

CI publishes to ghcr.io/startuppack/postal:latest (GitHub Container Registry, private org package) on every green main.


Bug fixes (this fork, vs. vanilla Postal)

Commit Fix
4b5bb7f config/initializers/omniauth.rb: add require "postal/oidc_providers" — file not eager-loaded at boot time caused NameError: uninitialized constant Postal::OIDCProviders
fe05672 Rename all OidcProvidersOIDCProviders across 5 files — Rails inflect.acronym "OIDC" causes Zeitwerk to expect OIDCProviders, not OidcProviders
1877937 Rename OidcLogoutControllerOIDCLogoutController in oidc_logout_controller.rb — same Zeitwerk acronym inflection requirement
9d2d721 Rename module Apimodule API in all app/controllers/api/v2/ files — Zeitwerk expects the constant to match the directory name api/API
2793959 app/views/sessions/new.html.haml: rename Postal::OidcProvidersPostal::OIDCProviders — missed by fe05672, caused 500 on login page
f09c937 CI: exclude spec/lib/dns_resolver_spec.rb from rspec — live DNS lookups against dnstest.postalserver.io are flaky in CI environments
adeda20 CI: publish amd64-only — drops QEMU arm64 cross-build (not needed, cluster is amd64); build time 40 min → 5 min
123ed60 message_db/connection.rb insert(): rescue PG::UndefinedColumn when appending RETURNING id — the migrations table has version as PK (not id); retry as plain INSERT
93d9fa3 message_db/provisioner.rb create_table_query_pg: prefix all PG index names with {table_name}_ — PG index names are schema-scoped (unlike MySQL where they are table-scoped), causing PG::DuplicateTable when two tables share the same index name (e.g. on_message_id)
b7e1c5a message_db/provisioner.rb: add portable add_column, add_index, modify_column public methods; rewrite migrations 11–20 that used raw MySQL DDL (backtick quoting, USING BTREE, CONVERT TO CHARACTER SET utf8mb4) to use these helpers
7716f73 message_db/provisioner.rb PG_TYPE_MAP: add tinyint (no width) → smallint mapping — MySQL Mysql2 gem returns tinyint(1) as boolean but bare tinyint as integer (0/1); PG must match. Migration 17 parsed column regressed to tinyint(1) (→ boolean), fixed back to tinyint DEFAULT 0 (→ smallint)
fd06996 message_db/migrations/19_convert_database_to_utf8mb4.rb: guard with return if @database.provisioner.postgresql? — charset conversion (CONVERT TO CHARACTER SET utf8mb4) is a MySQL-only operation; PG is always UTF-8

Known divergences from upstream (CI)

  • db:schema:load instead of db:migrate in CI (bypasses Authie gem unversioned migration)
  • spec/senders/smtp_sender_spec.rb: timeout context uses /timeout/ regex (upstream says error)
  • spec/lib/worker/jobs/process_queued_messages_job_spec.rb: :locked factory uses locked_at: 1.minute.ago to avoid stale-lock race at 300s boundary
  • spec/lib/postal/message_db/connection_pool_spec.rb: pool now yields Postal::MessageDB::Connection (not raw Mysql2::Client) — matchers updated
  • spec/lib/dns_resolver_spec.rb: excluded from CI (live DNS, flaky in GitHub Actions)
  • CI runs the full rspec suite (806 examples) against MySQL adapter only; PG compatibility is validated at runtime

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions