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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions