Skip to main content

InboxOps: Product & Architecture Specification

Table of Contents


This document is the single source of truth for what InboxOps is, what it must do, and what it is built with. It is written for engineers and product: a developer reading it should fully understand the product, its features, and the technical decisions behind it.


1. Product Definition

1.1 What InboxOps Is

InboxOps is an email-first ticketing SaaS. Customers connect their existing email addresses (inboxes) to the product; incoming messages become tickets, and agents reply from the same UI. All of this runs in a multi-tenant system: each customer (tenant) has isolated data, branding, and configuration. The product is built for multi-tenancy from day one.

1.2 Who It Is For

  • SMBs — small and medium businesses that need simple, affordable support or sales inbox management.
  • Small agencies — teams managing client communication or internal requests via email.
  • Growing teams — organizations that want a clean ticketing workflow without enterprise complexity.

These users expect a product that is easy to understand, quick to set up, and scalable as they grow.

1.3 Problem It Solves

Many teams use shared inboxes (e.g. support@, hello@) but lack structure: emails get lost, ownership is unclear, and there is no single place to see history or assign work. InboxOps turns an email inbox into a ticket queue: every conversation is a ticket, with status, assignment, and thread history in one place. Replies go out from the tenant's own email address, so customers see no change in who they're talking to. The product avoids the complexity of heavy ITSM tools while giving teams visibility and control.

1.4 Market Context & Positioning

The space InboxOps competes in:

Competitor typeExamplesWhy InboxOps is different
Heavy ITSM / helpdeskZendesk, FreshdeskToo complex, per-agent pricing is expensive for small teams, steep onboarding
Shared inbox toolsMissive, FrontCollaboration-first, not ticketing-first; limited workflow
SMB-focused ticketingHelp Scout, GrooveGood products but per-user pricing; limited multi-tenancy for agencies
Open source self-hostedZammad, osTicketRequire infrastructure knowledge; no SaaS convenience

InboxOps' positioning: Simple, affordable, ticketing-only SaaS for teams that live in email and don't want to migrate away from their own domain or address. Per-inbox pricing keeps costs predictable. Direct mailbox connection means no "noreply@" setup — identity stays with the tenant.

Key differentiators:

  • Connects directly to Google Workspace, Microsoft 365, or any IMAP mailbox — no third-party relay
  • Per-inbox pricing scales with how teams actually use the product
  • Guided onboarding and wizards make setup fast for non-technical admins
  • Multi-tenant architecture supports agencies managing multiple client inboxes from one account

1.5 Product Vision

  • Email-first: The inbox is the source of truth; tickets are created from real email, and replies are sent as real email from the tenant's address.
  • Multi-tenant by design: Tenancy is not an afterthought. Data, configuration, and billing are isolated per tenant.
  • Guided and simple: Setup wizards and clear flows so new tenants can go from signup to first ticket with minimal friction.
  • Clean, minimal UI: No clutter. Focus on tickets, threads, and actions. Available in dark and light mode.
  • Scalable: The same product and architecture serve small teams and larger organizations; limits and add-ons grow with the customer.

1.6 Core UX Principles

PrincipleMeaning
Guided onboardingNew tenants go through a step-by-step setup (org, first inbox, first user). No blank dashboard.
Setup wizardsComplex or one-time tasks (e.g. connecting an inbox, configuring billing) use wizards with clear steps and validation.
Clean, minimal UIOnly essential controls and information. No steep learning curve.
Dark and light modeFull theme support; user preference is persisted and applied globally.
Consistent patternsSame interaction patterns across the app (lists, filters, detail views, empty states).

1.7 Pricing Model

  • Primary unit: Per inbox. Tenants pay for the number of active inboxes they use, not the number of users (agents).
  • Add-ons: Optional paid features (e.g. SSO, public API, advanced reporting) are gated by entitlements and billed separately or as part of higher tiers.

This aligns cost with how teams actually use the product (inboxes as the main resource) and keeps pricing predictable for small teams.

Plan tiers

PlanInboxes includedAdd-ons includedNotes
StarterUp to 2 inboxesFor individuals and very small teams
GrowthUp to 10 inboxesAdvanced reportingFor growing teams and small agencies
BusinessUp to 30 inboxesAdvanced reporting, Public API, WebhooksFor larger teams and agencies
EnterpriseUnlimited (custom)All add-ons incl. SSOCustom contract, volume pricing

Additional inboxes beyond the plan limit can be added as paid overages (e.g. per extra inbox per month) or by upgrading the plan. Stripe subscription quantity maps directly to the purchased inbox count. Plan IDs in code: starter, growth, business, enterprise.

All plans include: unlimited users (agents), all core ticketing features, dark/light mode, onboarding wizard, IMAP/Gmail/Microsoft connection types.

Free trial

  • Every new tenant gets a 30-day free trial on the Growth plan automatically on signup. No credit card required to start.
  • Trial state is stored on the tenant: tenants.trial_ends_at (timestamp). While trial_ends_at is in the future and no active subscription exists, the tenant has Growth-level access.
  • At day 25, a reminder email is sent (Postmark) to all tenant admins: "Your trial ends in 5 days."
  • At day 30, if no subscription exists: inbox creation is blocked, syncing continues (read-only grace period of 7 days), and a banner is shown in the app prompting the tenant to subscribe.
  • After the 7-day grace period with no subscription: tenant is locked (can only view existing tickets, no new inbound, no replies) until a plan is activated.
  • Stripe: no subscription is created during trial; Checkout is triggered when the tenant chooses a plan. A Stripe trial_end can be used if Stripe Billing is used for trial management, but the source of truth for trial state is our DB.
  • Trial entitlements: full Growth plan features (advanced reporting, all inboxes up to 10, webhooks via Business are not included in trial).
  • Plan IDs in code for trial state: trial; transitions to starter, growth, business, or enterprise on first subscription.

1.8 What Makes It Different

  • Uses the tenant's own mailbox: No "noreply@inboxops.com" — replies are sent from the connected address (IMAP/SMTP or Gmail/Microsoft 365). The tenant keeps their domain and identity.
  • Built for multi-tenancy: Isolation, billing, and onboarding are core to the architecture, not bolted on.
  • Focused scope: Email ticketing and collaboration only. No project management, asset management, or heavy workflow engines — so the product stays understandable and fast to adopt.

2. Feature Specification

Every feature below is part of the complete, shippable product. Features are grouped by product area. For each feature: Name, Description, Acceptance criteria, and Technical notes.


2.1 Tenant & Onboarding

Tenant provisioning

  • Description: A new customer can create a tenant (organization). The tenant has a name, a unique slug (used in path-based routing), and fully isolated data.
  • Acceptance criteria: Tenant record is created; slug is unique and URL-safe; all subsequent data (inboxes, tickets, users) is scoped to this tenant.
  • Technical notes: tenants table; slug used for path-based tenant resolution only (e.g. inboxops.app/app/acme/); no subdomain routing; slug must be unique and URL-safe.

Tenant onboarding wizard

  • Description: After signup or tenant creation, the user is guided through setup: confirm org details, create the first inbox (and connect email), invite or add the first users. Progress is visible; the user can exit and resume.
  • Acceptance criteria: Wizard has discrete steps; progress is persisted; completing the wizard marks onboarding done and lands the user on the main app (e.g. inbox or ticket list).
  • Technical notes: Onboarding state stored on tenant or user (e.g. onboarding_step, onboarding_completed_at). Middleware or layout can redirect incomplete tenants back to the wizard.

2.2 Inbox Management

Inbox CRUD

  • Description: Tenants can create, list, update, and archive inboxes. Each inbox has a display name and is linked to one primary email address/connection.
  • Acceptance criteria: Create requires name and connection type; list shows all non-archived inboxes with key info; update allows name and connection changes; archive hides from default list and counts toward plan only if policy says so (e.g. archived = not counted).
  • Technical notes: inboxes table: tenant_id, name, email_address, connection_type, archived_at, etc. Plan limits enforced on create (and optionally restore).

Connect inbox — IMAP/SMTP

  • Description: For an inbox, the tenant can connect a mailbox using IMAP (receive) and SMTP (send). User provides host, port, credentials; we validate and store them securely.
  • Acceptance criteria: User can enter IMAP/SMTP settings and test connection; on success, credentials are stored encrypted; inbox is marked connected and can receive/send.
  • Technical notes: Encrypted credential storage (e.g. per-inbox encrypted blob or vault); background worker uses credentials for fetch/send. Validation = test login and optional test send.

Connect inbox — Google Workspace / Gmail

  • Description: Tenant connects an inbox using Google OAuth2. InboxOps receives permission to read and send mail via Gmail API; we store tokens and use them for sync and sending.
  • Acceptance criteria: OAuth flow completes; tokens stored encrypted; inbox is connected; new messages are fetched and replies are sent via Gmail API.
  • Technical notes: Gmail API (OAuth2); token refresh handled by worker or auth layer; sync via API (polling or Gmail Push).

Connect inbox — Microsoft 365 / Outlook

  • Description: Tenant connects an inbox using Microsoft OAuth2. We use Microsoft Graph to read and send mail; tokens are stored and used for sync and sending.
  • Acceptance criteria: OAuth flow completes; tokens stored encrypted; inbox is connected; new messages are fetched and replies sent via Graph.
  • Technical notes: Microsoft Graph API (OAuth2); token refresh; sync via Graph (polling or change notifications).

OAuth token refresh failure handling

  • Description: When a Gmail or Microsoft OAuth2 access token expires and the refresh token fails (e.g. user revoked access, token expired, Google/Microsoft session invalidated), the inbox enters a disconnected error state. The system notifies admins and stops attempting to sync until the inbox is reconnected.
  • Acceptance criteria:
    • If token refresh fails, the inbox last_error is updated with a clear error code (e.g. oauth_refresh_failed) and connected_at is cleared.
    • The inbox status in the UI shows "Disconnected — reconnection required" with a CTA to re-authenticate.
    • An in-app notification and email (Postmark) are sent to all tenant admins: "Inbox [name] has been disconnected — please reconnect."
    • The worker stops scheduling further sync jobs for this inbox until it is reconnected.
    • No data loss: previously synced tickets and messages are preserved; the inbox resumes syncing from where it left off after reconnection (using stored last-seen UID or history ID).
    • Reconnection reuses the existing inbox record; no duplicate inbox is created.
  • Technical notes: Token refresh is attempted by the worker before each sync. On refresh failure (HTTP 400/401 from Google/Microsoft), the worker catches the error, sets inboxes.last_error = 'oauth_refresh_failed' and inboxes.connected_at = null, enqueues a "notify admins" job, and returns without retrying. Sync jobs for this inbox are skipped (check connected_at IS NULL before scheduling). Reconnect flow triggers the OAuth flow again and updates tokens in place.

Inbox connection status and health

  • Description: Each inbox shows connection status (connected, disconnected, error). When there is an error (e.g. token expired, auth failed), the UI shows a clear message and a path to re-authenticate or fix settings.
  • Acceptance criteria: Status is visible in inbox list and inbox settings; errors are surfaced with actionable next steps; reconnecting updates status.
  • Technical notes: inboxes.last_error, inboxes.connected_at or similar; worker or cron can set status from last sync result.

Multiple addresses per inbox (aliases)

  • Description: An inbox can have one primary connection plus optional additional sending addresses or aliases (e.g. support@ and help@ both map to the same inbox). Inbound can be accepted for any of these if the connection supports it.
  • Acceptance criteria: Tenant can add/remove aliases for an inbox; incoming mail to any listed address creates/updates tickets in that inbox; reply-from can use primary or alias where supported.
  • Technical notes: inbox_aliases or inbox.email_aliases (array); matching on To/Cc for ticket creation; outbound From selection per provider capabilities.

Auto-responses (out-of-office, first-reply)

  • Description: Tenant can configure automatic replies for an inbox: e.g. out-of-office message or a first-reply template sent once per conversation when a new ticket is created.
  • Acceptance criteria: Settings for enabling and editing auto-response text; first-reply is sent exactly once per ticket when configured; OOO can be time-bounded or manual toggle.
  • Technical notes: Settings on inbox or inbox_settings; worker sends auto-response after creating ticket or on first message; idempotency so we don't double-send.

2.3 Email Ingestion & Processing

Fetch new messages (IMAP)

  • Description: For IMAP-connected inboxes, a background worker fetches new messages (poll or IMAP IDLE), parses them, and creates or updates tickets and threads.
  • Acceptance criteria: New messages appear as new tickets or new messages in existing thread; threading is correct (In-Reply-To / References); attachments and metadata are stored.
  • Technical notes: Worker per inbox or batched; store last-seen UID (or equivalent) to avoid reprocessing; use message-id and threading headers for conversation grouping.

Fetch new messages (Gmail API)

  • Description: For Gmail-connected inboxes, the system fetches new messages via Gmail API (polling or Gmail Push). Messages are parsed and create/update tickets and threads.
  • Acceptance criteria: Same as IMAP: new messages become tickets or thread replies; threading and metadata preserved.
  • Technical notes: Gmail API history or messages.list; push subscription can trigger worker; map Gmail thread id to our thread/ticket model.

Fetch new messages (Microsoft Graph)

  • Description: For Microsoft 365–connected inboxes, the system fetches new messages via Graph (polling or change notifications). Messages are parsed and create/update tickets and threads.
  • Acceptance criteria: Same as IMAP/Gmail: correct ticket creation and threading.
  • Technical notes: Graph Mail API; delta or change notifications; map Graph conversation/message to our ticket and message model.

Create ticket from incoming email

  • Description: When a new email arrives that is not a reply to an existing thread, a new ticket is created. Subject, body, sender, and timestamp are stored; the first message is the first entry in the thread.
  • Acceptance criteria: One new ticket per new conversation; subject and first message body visible; sender and date stored; ticket is associated with the correct inbox and tenant.
  • Technical notes: tickets table; messages table with direction: 'inbound'; link message to ticket; apply auto-response if configured.

Append message to existing thread

  • Description: When an incoming email is identified as a reply (via In-Reply-To/References or provider thread id), it is appended to the existing ticket's thread and does not create a new ticket.
  • Acceptance criteria: Message appears in correct ticket thread; order is correct; no duplicate tickets for the same conversation.
  • Technical notes: Match by message-id / in-reply-to / thread id; insert message with ticket_id; update ticket updated_at and any "last message" cache.

Send reply (outbound)

  • Description: When an agent sends a reply from the UI, the system sends the email via the inbox's connection (SMTP or provider API), stores the sent message in the thread, and updates the ticket.
  • Acceptance criteria: Email is delivered from the tenant's address (or chosen alias); message appears in the ticket thread as outbound; In-Reply-To/References set so threading is correct in the client.
  • Technical notes: Use inbox credentials/tokens; send via SMTP or Gmail/Graph API; store outbound message with direction: 'outbound'; set threading headers.

Attachments (inbound and outbound)

  • Description: Inbound attachments are stored and displayed in the ticket thread; agents can attach files to replies and they are sent with the email.
  • Acceptance criteria: Inbound attachments are viewable and downloadable; outbound attachments are sent and visible in thread; storage is tenant-isolated and secure.
  • Technical notes: Object storage (e.g. S3-compatible) per tenant or with tenant prefix; attachments table or message.attachment_ids; signed URLs for download; size limits enforced.

Email edge case handling

  • Description: The ingestion pipeline handles edge cases gracefully: spam/junk detection, bounce processing, out-of-office loop prevention, and duplicate message protection.
  • Acceptance criteria:
    • Emails flagged as spam or junk by the provider are skipped (not turned into tickets). No quarantine UI at launch; quarantine can be added later if needed.
    • Bounce messages (delivery failure notifications) are detected and linked to the outbound message — not turned into a new ticket. In the UI, the linked outbound message shows a "Delivery failed" or "Bounce received" indicator so agents can see delivery status.
    • Auto-reply / out-of-office detection prevents infinite loops (we don't auto-respond to auto-replies).
    • Duplicate message-ids are ignored; re-ingestion of already-seen messages is idempotent.
  • Technical notes:
    • Spam: check X-Spam-Status header or Gmail/Graph spam labels; skip message (no ticket created).
    • Bounces: detect Content-Type: message/delivery-status or Auto-Submitted: auto-replied headers; link to the originating outbound message by setting messages.bounce_received_at on that message (see Section 5.6). No separate bounce table. UI shows delivery-failed state on that message.
    • OOO loop prevention: check Auto-Submitted, X-Auto-Response-Suppress, Precedence: bulk/auto_reply headers before sending auto-responses.
    • Deduplication: message_id_header must be unique per inbox or globally; upsert-safe ingestion.

2.4 Ticket Management

Ticket list and filters

  • Description: Agents see a list of tickets (all or per inbox). List is filterable by status, assignee, inbox, date range, and tags. Sort by date, priority, or other fields. Search by subject or participant.
  • Acceptance criteria: List loads with pagination or virtual scroll; filters and sort persist in URL or state; search returns matching tickets; results are scoped to tenant and optionally inbox.
  • Technical notes: tRPC procedure with filters; index on tenant_id, inbox_id, status, assignee_id, created_at; full-text or ilike for search (see Section 2.11 — Search).

Ticket detail and thread view

  • Description: Opening a ticket shows the full conversation thread (chronological messages, inbound and outbound). The view includes subject, participants, status, assignee, and a reply composer.
  • Acceptance criteria: All messages in order; sender and timestamp visible; reply box is visible and focused; UI updates when new messages arrive (e.g. poll or real-time).
  • Technical notes: Messages loaded by ticket_id; reply submits via tRPC and triggers send job; optional WebSocket or polling for new messages.

Reply from UI

  • Description: Agent composes a reply in the ticket view, optionally adds attachments, and sends. The reply is sent via the inbox connection and appended to the thread.
  • Acceptance criteria: Reply is sent from the correct inbox address; it appears in the thread; customer receives the email; validation (e.g. non-empty body) and error handling (e.g. send failure) are clear.
  • Technical notes: tRPC mutation; enqueue send job (Inngest); worker performs send and inserts message; optimistic update or refresh after success.

Ticket status workflow

  • Description: Each ticket has a status. Supported statuses: Open, In Progress, Waiting (on customer), Resolved, Closed. Agents can change status from the ticket view or list; status is visible in list and detail.
  • Acceptance criteria: Status can be changed by agents; status is persisted and reflected everywhere; optional automation (e.g. auto-close after Resolved + N days) if configured.
  • Technical notes: tickets.status enum; status change mutation; optional status_updated_at for SLA or automation.

Assignee

  • Description: Tickets can be assigned to an agent (user). Assigned agent is shown in list and detail; unassigned tickets are visible to all agents (or by filter). Assignment can be changed by any agent with permission.
  • Acceptance criteria: Assignee is optional; list and detail show assignee; reassignment is immediate and visible; notifications sent on assignment (see Section 2.10).
  • Technical notes: tickets.assignee_id FK to users (or tenant_memberships); RLS or query scope by tenant.

Tags / labels

  • Description: Tenants can define tags (e.g. "billing", "bug"). Agents can add or remove tags on tickets. Tags are used for filtering and reporting.
  • Acceptance criteria: Tags are tenant-defined; multiple tags per ticket; add/remove from detail view; list filter by tag works.
  • Technical notes: tags table (tenant_id, name, color); ticket_tags junction; or tickets.tag_ids array; index for filter.

Internal notes

  • Description: Agents can add internal comments to a ticket. These are visible only to agents, not sent to the customer and not shown in the outbound email thread.
  • Acceptance criteria: Internal notes are clearly distinguished from thread messages; only agents see them; they are stored and displayed in ticket timeline.
  • Technical notes: messages with type: 'internal_note' or separate internal_notes table; excluded from outbound and from customer-facing thread.

Canned responses / snippets

  • Description: Tenants can create reusable reply snippets (e.g. "Thanks for contacting us…"). Agents can insert a snippet into the reply composer and edit before sending.
  • Acceptance criteria: Snippets are tenant-scoped; list and insert in composer; optional shortcut or dropdown; body can be edited after insert.
  • Technical notes: snippets table (tenant_id, name, body); tRPC list/one; UI component for picker and insert.

SLA (Service Level Agreements)

  • Description: Tenants can configure SLA policies per inbox or globally: a target first response time and a target resolution time. Tickets approaching or breaching SLA are visually flagged and can trigger notifications.
  • Acceptance criteria:
    • Admin can define SLA policies (e.g. "first response within 4 hours", "resolve within 24 hours") and assign to inboxes.
    • Tickets show SLA status: on track, due soon (e.g. within 20% of deadline), breached.
    • Breached or near-breached tickets can trigger an agent notification (see Section 2.10).
    • SLA clock pauses when ticket status is "Waiting" (waiting on customer) and resumes on next inbound reply.
    • Business hours can be configured per tenant (SLA counts only during business hours).
  • Technical notes: sla_policies(tenant_id, inbox_id nullable, first_response_hours, resolution_hours, business_hours_only); tickets.first_response_at, tickets.resolved_at; SLA status computed from policy + timestamps; worker or cron checks for near-breach and breach events; SLA paused during status = 'waiting'.

Business hours configuration

  • Description: Tenants can configure their business hours (days and times) and timezone. When an SLA policy has business_hours_only = true, the SLA clock only counts time during configured business hours.
  • Acceptance criteria:
    • Admin can set timezone and per-day start/end times (or mark a day as closed) in Settings → SLA.
    • At minimum: Mon–Fri 09:00–17:00 with configurable timezone as a sensible default.
    • Individual days can be fully closed (null start/end = not a business day).
    • SLA calculations correctly skip non-business hours and closed days when computing elapsed time.
    • Business hours config is shared across all SLA policies for the tenant (one config per tenant).
  • Technical notes: business_hours table (see Section 5.21); SLA elapsed time function takes created_at, first_response_at/resolved_at, business hours config, and computes net business-hours elapsed; used by both real-time SLA status display and the SLA cron job.

Auto-responses

  • Description: Each inbox can have an auto-response configured. When a new ticket is created from an inbound email, the system automatically sends a reply to the sender from the inbox address. Auto-responses are optional and off by default.
  • Acceptance criteria:
    • Admin can enable/disable auto-response per inbox.
    • Admin can write the auto-response body using a template with supported variables.
    • Auto-response is sent only on new ticket creation (first inbound message), not on subsequent replies to the same thread.
    • Auto-response is not sent if the inbound email is detected as an auto-reply itself (OOO loop prevention via Auto-Submitted, Precedence, X-Auto-Response-Suppress headers — see Section 2.3).
    • Auto-response is sent from the inbox's connected address with the inbox display_name.
    • The auto-response appears in the ticket thread as an outbound message (type email, direction outbound, clearly labelled "Auto-response").
  • Supported template variables:
VariableResolved to
{{ticket_number}}Sequential ticket ID e.g. 1042
{{org_name}}Tenant organization name
{{inbox_name}}Inbox display name
{{sender_name}}Name extracted from the inbound From header (if available)
  • Example template: Hi {{sender_name}}, thanks for reaching out! We've received your message and logged it as ticket #{{ticket_number}}. Our team will get back to you shortly. — {{org_name}}
  • Technical notes: inboxes.auto_response_enabled (boolean), inboxes.auto_response_body (text with template variables). On new ticket creation, if enabled and not an auto-reply, worker enqueues "send auto-response" Inngest job; job resolves variables, builds MIME, sends via inbox connection, inserts outbound message row with send_status: 'sent'.

2.5 User & Role Management

User sign up and sign in

  • Description: Users create an account (email + password) and sign in. Session is maintained; sign-out is available. Password reset via email is supported.
  • Acceptance criteria: Sign up creates user and starts session immediately; sign in establishes session; password reset flow works end-to-end; sessions are invalidatable.
  • Email verification: Disabled at launch. Users are signed in immediately after signup without requiring email verification. This removes friction from the onboarding funnel. Email verification can be enabled later if abuse becomes an issue. The email_verified field is stored in the DB and set to true on signup so the schema is ready.
  • Password requirements: Minimum 12 characters; no complexity rules (NIST SP 800-63B guidance). Enforced client-side (react-hook-form) and server-side (tRPC mutation). Common/breached password check is a post-launch enhancement.
  • Technical notes: Lucia auth; users table; sessions table; password hashed with bcrypt (cost factor 12); email for reset via Postmark password-reset template.

Tenant membership and roles

  • Description: A user can belong to multiple tenants. For each tenant, the user has one role: Admin, Agent, or Viewer. Admins can manage settings and users; Agents can work tickets and use most features; Viewers have read-only access.
  • Acceptance criteria: Invite or add user to tenant with role; role is enforced in API and UI (e.g. only Admin sees settings); membership list is manageable by Admin.
  • Technical notes: tenant_memberships (user_id, tenant_id, role); middleware/resolver checks role for protected routes; UI hides or disables actions by role.

Invite users

  • Description: Admins can invite users by email. Invitee receives an email with a link; accepting creates or links the account and adds them to the tenant with the chosen role.
  • Acceptance criteria: Invite sends email; link is time-limited and single-use; accept adds membership. New users (no existing account): after clicking the invite link, the user is shown a "Set your password" page (same token or invite flow); they set password once, then are logged in and redirected to the tenant. Existing users: one-click accept adds membership and redirects to the tenant. No separate "first login" step for existing users.
  • Technical notes: invitations table (email, tenant_id, role, token, expires_at); Postmark for invite email; accept endpoint validates token, creates user with password_hash: null if new, creates membership; if new user, redirect to set-password view (invite token reused or short-lived set-password token); after password set, session created and redirect to /app/[slug]/tickets.

SSO (SAML / OIDC) — add-on

  • Description: Tenants with the SSO entitlement can configure SAML or OpenID Connect. Sign-in can then be done via identity provider (IdP); existing users are linked by email or subject id.
  • Acceptance criteria: Admin configures IdP metadata and attributes; sign-in page offers "Sign in with SSO" when enabled; after IdP auth, user is logged in and linked to tenant.
  • Technical notes: Gated by TenantEntitlement (e.g. sso_enabled); auth library with SAML/OIDC support; tenant-level IdP config stored securely.

2.6 Billing & Subscriptions

Plan and inbox limit

  • Description: Each tenant has a subscription plan that defines how many inboxes they can use. The app enforces this limit when creating or restoring inboxes and may show upgrade prompts when at limit.
  • Acceptance criteria: Inbox count is accurate (active non-archived); creating an inbox when at limit is blocked with clear message; plan is visible in billing/settings.
  • Technical notes: subscriptions.inbox_limit checked server-side before inbox create; inbox_count derived from DB count of non-deleted inboxes; UI shows used/limit.

Stripe integration — Checkout and portal

The full Stripe billing integration covers three flows:

1. Starting a subscription (Checkout):

  • Triggered from: billing settings "Upgrade" CTA, trial expiry banner, or /app/[slug]/locked.
  • Flow: tRPC mutation billing.createCheckoutSession → server creates Stripe Checkout Session (customer_email, client_reference_id: tenantId, selected price_id, success_url/cancel_url) → returns URL → client redirects to Stripe Hosted Checkout.
  • On return: redirect to success_url shows a confirmation message only. Subscription is confirmed and state is updated via webhook, not the redirect — never trust the redirect alone.
  • stripe_customer_id stored in subscriptions row after first successful checkout.

2. Managing subscription (Customer Portal):

  • tRPC billing.createPortalSession → Stripe Billing Portal Session for stripe_customer_id → redirect.
  • Tenant upgrades, downgrades, updates payment, views invoices, cancels — all in Stripe's UI.
  • All changes flow back to InboxOps exclusively via webhooks.

Enterprise plan: Enterprise is not purchased via self-serve Checkout. Enterprise tenants are set up manually (e.g. Stripe Invoicing, custom contract, or a manually created Stripe subscription). No STRIPE_ENTERPRISE_PRICE_ID is required; the app treats plan_id: 'enterprise' when the subscription is created or updated via webhook from such a manual setup.

3. Stripe webhook handler (POST /api/stripe):

  • Verifies Stripe-Signature header against STRIPE_WEBHOOK_SECRET using stripe.webhooks.constructEvent.
  • All handlers are idempotent — safe to replay.
Stripe eventAction in InboxOps
checkout.session.completedCreate/update subscriptions: set plan_id, status: active, stripe_subscription_id, stripe_customer_id, inbox_limit; sync entitlements
customer.subscription.updatedUpdate plan_id, status, inbox_limit, current_period_end; re-sync entitlements
customer.subscription.deletedSet status: cancelled; clear entitlements; if within grace period keep read access, else lock
invoice.payment_succeededUpdate current_period_end; clear past_due if set
invoice.payment_failedSet status: past_due; notify tenant admins via Postmark
invoice.payment_action_requiredSame as payment_failed; include Stripe-hosted payment link in notification

Entitlement sync (run after every subscription change):

PlanEntitlements granted
trialadvanced_reports
starter(none)
growthadvanced_reports
businessadvanced_reports, public_api, webhooks
enterpriseadvanced_reports, public_api, webhooks, sso

Implementation: delete all tenant_entitlements rows for tenant, insert new set based on plan. Idempotent and replayable from any webhook event.

Add-on entitlements

  • Description: Paid add-ons are represented as tenant_entitlements rows. Features check entitlement server-side (tRPC procedures) and client-side (UI gating with upgrade prompt).
  • Technical notes: tenant_entitlements(tenant_id, feature_key, enabled); entitlement check helper shared between tRPC context and UI; features not hidden — shown with "Upgrade" badge.

2.7 UI & Theming

App shell and navigation

  • Description: The app has a consistent shell: logo/branding, primary nav (e.g. Inboxes, Tickets, Settings), user menu (profile, theme, sign out). Navigation is clear and works on desktop and tablet. Tenant (workspace) switcher: Users who belong to multiple tenants can switch workspace from the app shell (e.g. dropdown or sidebar entry showing current org name/slug). Selecting another tenant navigates to /app/[new_slug]/tickets; the slug segment updates and all subsequent requests are scoped to the selected tenant. The switcher is available in the sidebar or user menu; current tenant is persisted (e.g. in session or cookie) so that the next login or reload opens the last-used tenant when possible.
  • Acceptance criteria: Shell is present on all authenticated pages; nav reflects role (e.g. Settings only for Admin); mobile/tablet layout is usable (collapse or drawer). When the user has multiple tenant memberships, a workspace/tenant switcher is visible and switching updates the URL and context.
  • Technical notes: Next.js layout; nav items driven by role; responsive breakpoints. Tenant switcher: list tenants from tenant_memberships for current user; on select, redirect to /app/[slug]/tickets; optionally store last_tenant_id or slug in session/cookie for post-login redirect.

Dark and light mode

  • Description: The user can switch between light and dark theme. Preference is persisted (e.g. cookie or localStorage) and applied globally without flash of wrong theme.
  • Acceptance criteria: Toggle in UI; theme persists across reloads; all pages and components respect theme; no FOUC where possible (script in head or class on root).
  • Technical notes: CSS variables + Tailwind dark:; theme provider stores preference; apply class or data-theme on <html>.

Responsive layout

  • Description: Lists, ticket detail, and forms are usable on desktop and tablet (and mobile where applicable). No horizontal scroll; touch targets are adequate.
  • Acceptance criteria: Layout adapts to viewport; ticket list and detail can be side-by-side on desktop or stacked on small screens; tables or cards adapt.
  • Technical notes: Tailwind responsive classes; breakpoint strategy documented in design; shadcn/ui components are responsive by default.

Empty states and loading

  • Description: Every list and major view has an empty state with a clear next action (e.g. "Create your first inbox"). Loading states use skeletons or spinners to avoid layout shift.
  • Acceptance criteria: Empty state has copy and CTA; loading state is visible during fetch; no content jump when data loads.
  • Technical notes: Shared empty-state and skeleton components; loading handled in server components or Suspense where appropriate.

Accessibility

  • Description: UI is keyboard navigable; focus order is logical; ARIA and semantics are used where needed; color contrast meets WCAG AA for text and controls.
  • Acceptance criteria: Tab through interactive elements; submit and cancel with keyboard; screen reader can understand structure and state; contrast ratios pass.
  • Technical notes: shadcn/ui is accessible by default; audit critical flows; document any custom patterns.

2.8 Integrations (Webhooks, Public API)

Outbound webhooks

  • Description: Tenants can configure one or more webhook endpoints. When events occur (e.g. ticket created, ticket replied, ticket closed), we send an HTTP POST with a JSON payload and signature. Retries with exponential backoff on failure.
  • Acceptance criteria: Admin can add/edit/remove webhook URLs; events are documented; payload includes event type, ticket id, tenant id, and relevant data; signature allows verification; failed deliveries are retried (see retry schedule below); optional dead-letter or log.
  • Technical notes: webhook_endpoints table (tenant_id, url, secret, events[]); job per event; HMAC signature in header; idempotency by event id. Retry schedule: Inngest (or equivalent) retries with exponential backoff; max 10 attempts; typical schedule: 1m, 2m, 4m, 8m, 16m, 30m, 1h, 2h, 4h, 8h (configurable). After max attempts, delivery is marked failed and logged; no further automatic retries.

Webhook event payload schemas

Every webhook delivery is an HTTP POST with Content-Type: application/json and a signature header X-InboxOps-Signature: sha256=<hmac> (HMAC-SHA256 of the raw body using the endpoint secret).

All payloads share a common envelope:

{
"event": "ticket.created",
"event_id": "evt_01j...",
"tenant_id": "uuid",
"created_at": "2025-03-04T10:00:00Z",
"data": { ... }
}
Eventdata fields
ticket.createdticket_id, ticket_number, subject, status, priority, inbox_id, assignee_id (nullable), created_at
ticket.updatedticket_id, ticket_number, changed_fields (array of field names), full ticket object
ticket.assignedticket_id, ticket_number, assignee_id, assigned_by_user_id
ticket.status_changedticket_id, ticket_number, old_status, new_status, changed_by_user_id
ticket.closedticket_id, ticket_number, resolved_at
message.receivedmessage_id, ticket_id, ticket_number, direction: 'inbound', from_address, body_text (truncated to 500 chars), has_attachments
message.sentmessage_id, ticket_id, ticket_number, direction: 'outbound', sent_at
inbox.errorinbox_id, inbox_name, error (string), occurred_at

Payloads intentionally do not include full email bodies in most events (privacy; size). message.received includes a 500-char truncated body_text preview. Full content is retrievable via the Public API if needed.

Public API (REST)

  • Description: Tenants with the API entitlement can use a REST API to read and write tickets, messages, and optionally inboxes. API is authenticated (e.g. API key or OAuth2) and scoped to tenant. Outbound reply sending is not supported via the Public API — agents must send replies from the InboxOps UI; the API supports listing messages, adding internal notes, and creating/updating tickets only.
  • Acceptance criteria: Authentication required; endpoints for tickets (list, get, create, update), messages (list, add internal note), and optionally inboxes; rate limiting and clear errors; API docs (OpenAPI) available.
  • Technical notes: REST in same app or separate route; API key in header Authorization: Bearer <api_key> (key format: prefix ixk_ plus random segment; full key shown once on creation); tenant from key; rate limit by tenant; OpenAPI spec generated or maintained.

Public API endpoint reference

Base path: /api/v1/. Authentication: Authorization: Bearer <api_key> header. API keys use the format ixk_ followed by a random segment (e.g. ixk_a1b2c3d4...); the full key is shown only once at creation; the UI displays the key prefix (e.g. ixk_a1b2) for identification. All endpoints are tenant-scoped from the API key. Responses are JSON. Errors follow { "error": { "code": "string", "message": "string" } }. Sending outbound replies is not available via the API — use the app UI for replying to customers.

Tickets

MethodPathDescription
GET/ticketsList tickets. Query params: status, inbox_id, assignee_id, priority, page, per_page (max 100).
GET/tickets/:idGet single ticket by UUID or ticket_number.
POST/ticketsCreate ticket manually. Body: subject, inbox_id, priority (optional), assignee_id (optional).
PATCH/tickets/:idUpdate ticket. Allowed fields: subject, status, priority, assignee_id, inbox_id.
GET/tickets/:id/messagesList messages in ticket thread (excludes internal notes).
POST/tickets/:id/messagesAdd internal note. Body: body_html or body_text, type: 'internal_note'.
GET/tickets/:id/tagsList tags on ticket.
POST/tickets/:id/tagsAdd tag by ID or name. Body: tag_id or tag_name.
DELETE/tickets/:id/tags/:tag_idRemove tag from ticket.

Inboxes

MethodPathDescription
GET/inboxesList inboxes (active only).
GET/inboxes/:idGet single inbox.

Tags

MethodPathDescription
GET/tagsList all tenant tags.

Pagination: All list endpoints return { "data": [...], "meta": { "page": 1, "per_page": 25, "total": 142 } }.

Rate limits: 100 requests/minute per tenant. 429 response with Retry-After header on breach.

OpenAPI spec: Generated from route definitions and served at /api/v1/openapi.json. Browsable at /api/v1/docs.


2.9 Admin & Settings

Tenant settings

  • Description: Admins can edit tenant-level settings: organization name, slug (with validation), timezone, and other preferences. Slug change updates path-based routing immediately; the old slug stops working and must be communicated to users.
  • Acceptance criteria: Settings form saves and validates; slug uniqueness and format enforced; changes reflected across the app.
  • Technical notes: tenants table; tRPC update; path-based tenant resolution reads slug (no subdomain).

Audit log (admin)

  • Description: Admins can view an audit log of important actions: who did what and when. Log is read-only and tenant-scoped.
  • Acceptance criteria: Log visible in Settings; entries have actor, action, resource, timestamp; filterable by date and actor; retention documented.
  • Technical notes: audit_logs table; written from tRPC mutations and worker jobs; index by tenant and time.

Events that write to audit_logs:

Action keyTriggerResource
tenant.updatedOrg name, slug, or timezone changedtenant
inbox.connectedInbox successfully connectedinbox
inbox.disconnectedInbox disconnectedinbox
inbox.reconnectedInbox re-authenticatedinbox
inbox.updatedInbox name, display name, or auto-response changedinbox
inbox.deletedInbox deletedinbox
ticket.createdTicket created manually via UI or API only; tickets created from inbound email ingestion are not written to the audit log (to avoid noise)ticket
ticket.deletedTicket deleted by Adminticket
ticket.status_changedStatus changed by agent or Adminticket
ticket.assignedTicket assigned or reassignedticket
ticket.movedTicket moved to different inboxticket
message.deletedMessage deleted by Admin (GDPR)message
user.invitedInvitation sentinvitation
user.invite_revokedInvitation revokedinvitation
user.role_changedMember role changedtenant_membership
user.removedMember removed from tenanttenant_membership
sla_policy.createdSLA policy createdsla_policy
sla_policy.updatedSLA policy updatedsla_policy
sla_policy.deletedSLA policy deletedsla_policy
api_key.createdAPI key createdapi_key
api_key.revokedAPI key revokedapi_key
webhook.createdWebhook endpoint addedwebhook_endpoint
webhook.updatedWebhook endpoint editedwebhook_endpoint
webhook.deletedWebhook endpoint deletedwebhook_endpoint
subscription.changedPlan changed (via Stripe webhook)subscription
tenant.deletedTenant deletion initiatedtenant

Events from workers (e.g. inbox.disconnected on OAuth failure) use user_id: null (system actor). All writes are append-only; rows are never updated or deleted.

Audit log retention:

PlanRetention
Starter30 days
Growth90 days
Business1 year
EnterpriseCustom (configurable, min. 1 year)

Rows older than the tenant's retention period are pruned by a weekly cron job.

Feature flags (internal)

  • Description: The product supports feature flags (global and per-tenant) to turn features on or off without deployment. Used for rollouts and for gating paid add-ons.
  • Acceptance criteria: Backend and frontend can read flags; tenant-scoped flags override or extend global; changing a flag affects behavior without deploy.
  • Technical notes: DB-backed flags (e.g. feature_flags table); optional cache; evaluation in middleware or server-side only; entitlements may be implemented as flags.

2.10 Notification System

Overview

InboxOps generates two categories of notifications:

  1. System notifications — transactional emails sent to agents/admins (via Postmark), e.g. invite, password reset, ticket assignment.
  2. In-app notifications — real-time or polled alerts visible inside the app UI.

Customer-facing email (replies to tickets) is not part of the notification system — that goes through the inbox connection.

Notification events and recipients

EventChannelRecipient
User invited to tenantEmail (Postmark)Invitee
Password reset requestedEmail (Postmark)User
Email verificationEmail (Postmark)User
Ticket assigned to agentEmail + In-appAssigned agent
Ticket reassignedEmail + In-appNew assignee (and optionally previous)
New reply received on assigned ticketEmail + In-appAssigned agent
Internal note mentioning agent (@mention)Email + In-appMentioned agent
SLA breach / near-breachEmail + In-appAssigned agent, Admins
Inbox connection errorEmail + In-appTenant admins
Subscription / billing issueEmail (Postmark)Tenant admins
Trial ending in 5 daysEmail (Postmark)Tenant admins
Trial ended — no subscriptionEmail (Postmark) + In-app bannerTenant admins
Trial grace period ending (day 37)Email (Postmark)Tenant admins

Notification preferences

  • Each user can configure which notification types they receive and via which channel (email, in-app, both, none).
  • Preferences are per-tenant (user may belong to multiple tenants with different preferences).
  • Admins can set tenant-wide defaults.

In-app notification delivery

  • Notifications are stored in a notifications table and fetched via polling or WebSocket.
  • Unread count shown in nav; notification panel accessible from header.
  • Mark-as-read individually or all-at-once.

Technical notes

  • notifications(id, tenant_id, user_id, type, payload jsonb, read_at, created_at).
  • Notification creation is handled by Inngest functions triggered by domain events (ticket assigned, reply received, etc.).
  • Postmark is used only for delivery — all templates are rendered in-house (see Section 2.10b). No Postmark template IDs; we send fully rendered HTML to Postmark's sendEmail API.
  • Email notifications include a link back to the relevant ticket or page.
  • Notification fan-out (e.g. "new reply on ticket assigned to team") is handled by the worker, not the web app.

2.10b Email Templates

All system emails are built and rendered in InboxOps using React Email (react-email) — HTML is generated server-side and delivered via Postmark's API. Postmark is used as a delivery transport only; no Postmark template IDs or Handlebars templating.

Chosen library: react-email — React components that render to HTML email-compatible markup. Outputs clean, mail-client-safe HTML. Works with Tailwind (via @react-email/tailwind). Renders server-side in the worker.

Template list (all required before launch):

Template nameTriggerRecipientsKey variables
inviteUser invited to tenantInviteeorg_name, inviter_name, role, accept_url, expires_at
password-resetPassword reset requestedUserreset_url, expires_in
email-verificationSignup (if verification enabled)Userverify_url
ticket-assignedTicket assigned to agentAssigned agentticket_number, ticket_subject, assigned_by, ticket_url
ticket-replyNew inbound reply on assigned ticketAssigned agentticket_number, ticket_subject, from_address, body_preview, ticket_url
mention@mention in internal noteMentioned agentticket_number, ticket_subject, mentioned_by, note_preview, ticket_url
sla-breachSLA breachedAssigned agent + Adminsticket_number, ticket_subject, sla_type (first_response/resolution), breached_at, ticket_url
inbox-errorInbox connection error / OAuth failureTenant Adminsinbox_name, error_message, reconnect_url
trial-endingDay 25 of trialTenant Adminsorg_name, days_remaining, upgrade_url
trial-expiredDay 30, no subscriptionTenant Adminsorg_name, grace_period_ends, upgrade_url
trial-lockedDay 37, account will be lockedTenant Adminsorg_name, upgrade_url
payment-failedStripe invoice.payment_failedTenant Adminsorg_name, amount_due, payment_url
subscription-cancelledStripe customer.subscription.deletedTenant Adminsorg_name, cancelled_at, resubscribe_url

Rendering pipeline:

  1. Inngest job receives event data
  2. Imports the relevant React Email component
  3. Calls render(component) → HTML string + plain text
  4. POSTs to Postmark API: { From, To, Subject, HtmlBody, TextBody }

Shared design system: All templates share a BaseLayout wrapper with InboxOps branding (logo, footer with unsubscribe/legal links, consistent font and color scheme). Dark mode is supported via email-safe media queries where mail clients allow it.


  • Description: Agents can search across tickets and messages within their tenant. Full-text search covers ticket subjects and message bodies only. Sender address and tags are filter dimensions (dropdown/facet), not FTS — the search UI combines FTS with optional filters by sender and tag.
  • Acceptance criteria: Search returns relevant results scoped to the tenant; results show ticket title, inbox, status, and a snippet; search is fast enough for real-time typeahead or on-submit; special characters are handled safely.
  • Technical notes: PostgreSQL full-text search using tsvector/tsquery on tickets.subject and messages.body_text; index with GIN; tenant-scoped with tenant_id predicate. Sender and tag are applied as WHERE filters (e.g. from_address ilike or tag join), not as FTS terms. A separate search index (e.g. Typesense) can be added for larger tenants if Postgres FTS proves insufficient.

Search architecture decision

For the initial product, PostgreSQL full-text search is used:

AspectApproach
Indexingtsvector column on tickets and messages; updated via PostgreSQL generated column (stored)
tickets.tsv definitionGENERATED ALWAYS AS (to_tsvector('english', coalesce(subject, ''))) STORED
messages.tsv definitionGENERATED ALWAYS AS (to_tsvector('english', coalesce(body_text, ''))) STORED
Update strategyGenerated column — Postgres updates the tsv column automatically on every insert/update to the source column. No trigger or application code required. Drizzle does not yet support generated columns natively; define via raw SQL in the migration.
Querytsquery with websearch_to_tsquery('english', $1) — handles quoted phrases, OR, and negation in user input
Rankingts_rank(tsv, query) for relevance ordering
Tenant isolationAll queries include WHERE tenant_id = $1 before FTS predicates
LimitationsEnglish stemming only; no typo tolerance. Acceptable for the initial product.
FutureCan add a simple dictionary for non-English tenants, or migrate to Typesense/Meilisearch if needed

2.12 Reporting

Basic reporting (all plans)

  • Description: Agents and admins can view basic operational metrics for their tenant: ticket volume over time, ticket status distribution, average first response time, and average resolution time.
  • Acceptance criteria: Dashboard or reports page shows charts/numbers for the selected time range (last 7 days, 30 days, 90 days, custom); data is scoped to tenant; can filter by inbox; numbers are accurate and reflect current DB state.
  • Technical notes: Queries against tickets and messages tables with aggregation (COUNT, AVG, etc.); no separate analytics store needed at this scale; results can be cached briefly (e.g. 5 min) to avoid expensive repeated queries.

Metrics included in basic reporting:

MetricDescription
Tickets createdCount of new tickets per day/week
Tickets resolvedCount of resolved/closed tickets per period
Open ticket countCurrent snapshot by status
First response timeAvg time from ticket created to first outbound reply (first_response_at - created_at)
Resolution timeAvg time from ticket created to resolved (resolved_at - created_at)
Tickets by inboxVolume breakdown per inbox
Tickets by assigneeVolume and resolution rate per agent

Advanced reporting (Growth plan and above)

  • Description: Tenants with the advanced_reports entitlement get additional reporting capabilities: SLA compliance rates, tag-based analysis, agent workload, and data export.
  • Acceptance criteria: Reports are available in Settings or dedicated Reports section; all basic metrics plus: SLA breach rate per inbox, tag distribution, per-agent ticket count and avg response time, CSV export of raw ticket data for the selected period.
  • Technical notes: Same query-based approach as basic; SLA compliance requires joining with sla_policies and comparing first_response_at / resolved_at against policy targets; export generates CSV server-side (streamed or pre-generated for large sets).

Additional metrics in advanced reporting:

MetricDescription
SLA compliance rate% of tickets that met first response and resolution SLA targets
SLA breach countCount of breaches per inbox or period
Tag distributionTicket counts per tag
Agent workloadOpen + resolved tickets per agent at a given time
CSV exportRaw ticket data: id, subject, status, inbox, assignee, created, first_response, resolved, tags

2.13 Marketing Website

The marketing website lives on inboxops.app in the same Next.js project as the SaaS app, under the (marketing) route group. Marketing pages have no auth middleware and are fully statically rendered (SSG) for performance.

Pages required at launch

PageRoutePurpose
Landing page/Hero, value proposition, key features, social proof, CTA to signup
Pricing/pricingPlan comparison table, FAQ, CTA per plan, trial callout
Contact/contactContact form (name, email, message, company); submissions stored in DB and/or forwarded via Postmark
Terms of Service/legal/termsAGB — legally required for SaaS
Privacy Policy/legal/privacyDatenschutzerklärung (GDPR-compliant)
Imprint/legal/impressumImpressum — legally required in DE/AT/CH
404not-foundCustom not-found page with nav back to home

Stub at launch (not blocking): /blog — route exists, shows "Coming soon"; structure is in place for future articles.

Landing page structure (conversion-optimised)

SectionContent
HeroHeadline, sub-headline, primary CTA ("Start free trial"), secondary CTA ("See pricing"); optional product screenshot or animation
Problem / SolutionShort narrative: shared inboxes are chaos → InboxOps brings structure
Key features3–4 feature highlights: direct mailbox connection, per-inbox pricing, tickets + threads, team assignment
How it works3-step visual: connect inbox → emails become tickets → team replies together
Pricing teaserCondensed plan overview with link to /pricing
Social proofPlaceholder for testimonials / logos (can launch with placeholder copy)
Final CTARepeat signup CTA with trial framing: "30 days free, no credit card required"
FooterNav links (Pricing, Contact, Legal), copyright, theme toggle

Pricing page

  • Shows all four plans (Starter, Growth, Business, Enterprise) in a comparison table.
  • Annual / monthly toggle with pricing for each (annual = ~2 months free; actual prices TBD and managed as content, not hardcoded).
  • Feature comparison rows: inboxes included, users, advanced reports, public API, webhooks, SSO.
  • CTA per plan: "Start free trial" → /signup; Enterprise → /contact.
  • FAQ section below the table covering common billing and feature questions.
  • Pricing content is managed as static data (TypeScript config file), not hardcoded in JSX, so it can be updated without component changes.

Contact form

  • Fields: Name, Email, Company (optional), Subject (dropdown: General, Sales, Support, Partnership), Message.
  • Validation: client-side (react-hook-form + Zod) and server-side (Route Handler).
  • On submit: stores submission in contact_submissions DB table (id, name, email, company, subject, message, created_at); sends notification email to a configured CONTACT_EMAIL address via Postmark.
  • Anti-spam: honeypot field + rate limit (max 3 submissions per IP per hour).
  • Success state: inline confirmation message; no page redirect.
  • Content is managed as MDX files in apps/web/content/legal/ — editable without code changes.
  • Rendered with a shared LegalLayout wrapper (clean typography, ToC for long pages, last-updated date).
  • Must be reviewed by a lawyer before launch. Placeholder text in repo is clearly marked [LEGAL REVIEW REQUIRED].
  • Language: For DE/AT/CH compliance, Impressum and Datenschutz (Privacy Policy) are typically required in German. AGB (Terms) may be in English or German depending on target market. Legal content can be provided in German in the same MDX files (e.g. content/legal/impressum.de.mdx or a single locale); if the marketing site uses next-intl, configure a default locale or route so that /legal/impressum serves German content for the primary market. Document the chosen approach (single language vs. locale-based legal pages).

Marketing site technical decisions

AspectDecision
RenderingStatic (SSG via Next.js); all marketing pages are export const dynamic = 'force-static'
i18nUses same next-intl setup; all marketing copy in messages/en/marketing.json
AnalyticsPlausible Analytics (privacy-friendly, no cookie banner needed for GDPR); script injected in marketing layout only — not in the app shell
Cookie bannerNot needed for marketing site (Plausible is cookieless); session cookie in app is strictly necessary and exempt
SEOgenerateMetadata per page; sitemap at /sitemap.xml (auto-generated); robots.txt allows all except /app/**
OpenGraph imagesStatic OG image for landing/pricing; route-specific meta descriptions
CMSNo external CMS at launch; content lives in MDX files and TypeScript config. Can migrate to a headless CMS later.

New DB table: ContactSubmission

FieldTypeNotes
iduuidPK
namestring
emailstring
companystring nullable
subjectstringDropdown value
messagetext
ip_addressstring nullableFor rate limiting; hashed or truncated for GDPR
created_attimestamp

New env vars for marketing site

VariableRequiredDescription
CONTACT_EMAILEmail address that receives contact form submissions (e.g. hello@inboxops.app)
NEXT_PUBLIC_PLAUSIBLE_DOMAINDomain string for Plausible Analytics (e.g. inboxops.app); omit to disable analytics in dev

3. Tech Stack Decisions

For each layer, the chosen technology is stated, alternatives are noted, and the reason for the choice is given. These are definitive decisions.

3.1 Monorepo and package management

DecisionChoiceAlternatives consideredReason
Monorepo toolingTurborepoNx, Lerna, pnpm workspaces onlyTurborepo gives fast, cached builds and clear task pipelines with minimal config. Fits TypeScript/Node monorepos well.
Package managerpnpmnpm, Yarnpnpm's strict dependency resolution and disk efficiency suit monorepos; workspace protocol works cleanly with Turborepo.

3.2 Frontend

DecisionChoiceAlternatives consideredReason
FrameworkNext.js (App Router)Remix, Vite + React RouterNext.js provides SSR, routing, API routes, and a single deployment unit. App Router gives RSC and clear data-fetching patterns.
StylingTailwind CSSCSS Modules, styled-componentsTailwind enables fast, consistent UI work and pairs well with shadcn/ui; utility-first keeps custom CSS minimal.
Component libraryshadcn/uiMUI, Chakra, Radix onlyshadcn/ui is copy-paste components (Radix + Tailwind), full control over code, accessible, and themable for dark/light.
Rich text editorTiptapQuill, Slate.js, TinyMCETiptap is headless, ProseMirror-based, React-compatible, and extensible. Outputs HTML. Supports mentions, custom extensions. No legacy baggage like Quill.
Data tablesTanStack Tableshadcn Table only, AG GridTanStack Table is headless and composable; renders with shadcn/Tailwind. Handles sorting, filtering, pagination for ticket lists, audit logs, etc.
ChartsRechartsChart.js, VictoryRecharts is React-native, composable, and works well with Tailwind theming. Sufficient for bar and line charts in reporting.
Form handlingreact-hook-form + ZodFormik, native formsreact-hook-form is performant and uncontrolled; Zod provides shared validation with tRPC input schemas.
Date handlingdate-fnsdayjs, Luxondate-fns is tree-shakeable and TypeScript-native; no global state. Used for formatting, relative time, and SLA calculations.
Toast notificationssonnerreact-hot-toast, shadcn Toastsonner is minimal, accessible, and pairs well with shadcn/ui.
Theme managementnext-themesManual CSS class togglenext-themes handles SSR-safe theme switching and system preference detection; prevents FOUC.
Internationalisationnext-intlreact-i18next, linguinext-intl is built for Next.js App Router: works with RSC, supports ICU message format, generates typed message keys, and has a clean namespace-per-feature pattern. Compatible with standard .json message files used by Weblate and other translation management tools.

3.3 API layer

DecisionChoiceAlternatives consideredReason
Internal app APItRPCREST, GraphQLtRPC gives end-to-end type safety with minimal boilerplate; no codegen or separate API spec for the main app.
Webhooks and public APIRESTtRPC, GraphQLREST is standard for webhooks and third-party integrations; tooling and customer expectations are well understood.

3.4 Database and ORM

DecisionChoiceAlternatives consideredReason
DatabasePostgreSQL via NeonSupabase, PlanetScale, self-hosted PostgresNeon is serverless Postgres with branching (good for staging), connection pooling, and no server management.
ORM / query layerDrizzle ORMPrisma, Kysely, raw SQLDrizzle is TypeScript-first, lightweight, and works well with RLS and raw SQL when needed; migrations are explicit SQL.

3.5 Auth

DecisionChoiceAlternatives consideredReason
Auth libraryLuciaClerk, Better Auth, NextAuthLucia is self-hosted, session-based, and works with any DB (we use Drizzle). No per-seat cost or vendor lock-in; we keep control of user and session data.

3.6 Email: inbox connection and delivery

DecisionChoiceAlternatives consideredReason
Inbound (fetch)Direct mailbox — IMAP (IDLE/poll), Gmail API, Microsoft GraphInbound API (SendGrid/Mailgun webhook)Product requirement: tenants connect their own mailbox. IMAP + Gmail + Graph cover most providers; we own the sync worker and token storage.
Outbound (replies)Same connection — SMTP or provider API (Gmail/Graph) using the connected mailboxDedicated sending service (e.g. SendGrid)Replies must come from the tenant's address; using the same connection keeps identity and threading correct and avoids extra provider setup.
Credential storageEncrypted per inboxExternal vault (e.g. HashiCorp)Encrypted column or blob per inbox is sufficient for initial build; can migrate to a dedicated vault if compliance requires it.
IMAP clientimapflownode-imap, imapimapflow is actively maintained, Promise/async-based, supports IMAP IDLE natively, and has a clean API. node-imap is callback-based and largely unmaintained.
MIME parsingmailparser (from Nodemailer)postal-mime, emailjs-mime-parsermailparser is battle-tested, handles encoding quirks and multipart MIME well, and is the de-facto standard in the Node email ecosystem.
Email construction (outbound)nodemailermailcomposer standalonenodemailer builds and sends MIME emails via SMTP. For Gmail/Graph sends, we use the provider API directly but still use nodemailer to construct the RFC-2822 MIME payload.

Customer-facing email (replies) is sent only through the tenant's connected mailbox. There is no separate "sending provider" for that path.

3.7 System notifications (transactional email)

DecisionChoiceAlternatives consideredReason
Transactional deliveryPostmarkSendGrid, Resend, AWS SESPostmark is focused on transactional delivery with strong deliverability. Used only for system emails, not customer-facing replies.
Email template renderingreact-emailPostmark templates, MJML, hand-written HTMLreact-email renders React components to mail-client-safe HTML server-side. Full control over design, no vendor lock-in to Postmark templates, works with TypeScript, can be tested and previewed locally.

3.8 Async jobs and queue

DecisionChoiceAlternatives consideredReason
Job queueInngestBullMQ + Redis, Trigger.devInngest is managed, type-safe, and works with serverless (no Redis to run). Fits Fly.io and serverless workers; retries and observability are built in.

3.9 Billing

DecisionChoiceAlternatives consideredReason
Payments and subscriptionsStripePaddle, Lemon SqueezyStripe is the standard for subscriptions, webhooks, and portal; we use it as the source of truth for plan and add-ons.

3.10 Feature flags

DecisionChoiceAlternatives consideredReason
Feature flagsDB-backedLaunchDarkly, UnleashedSimple flags in our DB (e.g. feature_flags table) avoid an external dependency and are enough for per-tenant and global toggles; can add a dedicated service later if needed.

3.11 Hosting and environment

DecisionChoiceAlternatives consideredReason
Application hostingFly.ioVercel, Railway, AWSFly.io gives full control over regions, persistent processes for workers, and fits both HTTP app and background workers in one place.
Database hostingNeonAlready chosen; serverless Postgres with branching.
StagingFly.io staging app + Neon branchSeparate staging DB onlyStaging app on Fly.io points to a Neon staging branch; same code, isolated data; branch can be reset or cloned from production.

3.12 CI/CD and testing

DecisionChoiceAlternatives consideredReason
CI/CDGitHub ActionsCircleCI, GitLab CIGitHub Actions is integrated with the repo and sufficient for build, test, and deploy; no extra vendor for basic pipelines.
Unit and integration testsVitestJestVitest is fast and ESM-friendly; good for Node and TypeScript.
End-to-end testsPlaywrightCypressPlaywright supports multiple browsers and is well suited for testing full flows (auth, tickets, replies) in a real browser.

3.15 Error tracking and observability

DecisionChoiceAlternatives consideredReason
Error trackingSentryDatadog, Bugsnag, RollbarFirst-class Next.js and Node.js SDKs, source map support, session replay for frontend issues, generous free tier. Single Sentry project covers both apps/web and apps/worker.
Log aggregationLogtail (Better Stack)Datadog Logs, PapertrailFly.io forwards stdout logs to Logtail with minimal config; structured log support; pairs with uptime monitoring below.
Uptime monitoringBetter Stack UptimePingdom, UptimeRobotSame platform as Logtail; monitors /api/health endpoint; alerts on downtime via email/Slack.

Sentry integration points:

LocationWhat is captured
apps/web — Server Components, Route HandlersUnhandled server-side exceptions; performance traces
apps/web — Client-side ReactUnhandled JS errors; session replay on error for reproducing UI bugs
apps/worker — Inngest functionsUncaught errors in all function handlers; email sync failures; send failures; webhook delivery errors
packages/emailIMAP/SMTP/Gmail/Graph client errors tagged with inbox_id, tenant_id
packages/billingStripe webhook handler errors; failed entitlement syncs

Sentry configuration rules:

  • tenant_id and inbox_id are attached as Sentry tags on every captured event (enables filtering by tenant).
  • Never include in Sentry events: credentials_encrypted, OAuth tokens, email body content, or any user-identifiable PII. Use the beforeSend hook to strip sensitive fields before transmission.
  • Source maps uploaded to Sentry at build time via @sentry/nextjs webpack plugin. Source maps are not shipped in the production JS bundle.
  • Sentry environment set from NODE_ENV: production, staging, development.
  • Sample rates in production: 100% for errors, 10% for performance traces (adjustable per environment).
  • A /api/health endpoint returns { status: "ok", version: "<git-sha>" } — used by uptime monitoring and deployment health checks.

3.13 Internationalisation (i18n)

Current language: English only. The UI is built entirely in English for the initial launch. i18n infrastructure is in place from day one so that additional locales can be added later without refactoring.

Chosen library: next-intl

DecisionChoiceReason
i18n librarynext-intlFirst-class App Router support; works with RSC and Server Components; ICU message format; type-safe message keys via codegen; standard JSON format
Message formatICUHandles plurals, gender, and interpolation correctly; supported by Weblate, Phrase, Crowdin, and other TMS tools
Message filesJSON per locale per namespacee.g. messages/en/tickets.json, messages/en/settings.json; namespacing keeps files manageable
Translation managementWeblate-compatibleJSON + ICU format is natively supported by Weblate (self-hosted or weblate.org); also compatible with Phrase, Crowdin, Lokalise

File structure:

messages/
├── en/
│ ├── common.json # Shared: buttons, status labels, errors
│ ├── tickets.json # Ticket list, detail, composer
│ ├── inboxes.json # Inbox management
│ ├── settings.json # All settings pages
│ ├── billing.json # Plans, trial, billing UI
│ ├── onboarding.json # Wizard steps
│ ├── notifications.json # Notification panel, preferences
│ ├── reports.json # Reports page
│ └── auth.json # Login, signup, reset password
└── de/ # Future: added without code changes
└── ...

Implementation rules:

  • Every user-visible string in the UI goes through next-intl's useTranslations hook (client) or getTranslations function (server). No hardcoded English strings in components.
  • Message keys are typed — TypeScript will error on a missing or mistyped key. Codegen runs as part of the build.
  • Locale is set globally (default en). No per-user locale switching in v1 — the app is English-only for launch. The infrastructure for locale switching is present but the toggle is not exposed in the UI yet.
  • Dates and numbers use next-intl's useFormatter for locale-aware formatting (e.g. formatRelativeTime, formatDateTime).
  • Email templates (react-email, Section 2.10b) are not run through next-intl — they are English-only strings for now. Localised emails are a post-launch concern.

Adding a new locale later:

  1. Create messages/<locale>/ and copy all JSON files from en/.
  2. Translate values (via Weblate or manual).
  3. Add locale to next-intl config.
  4. Expose locale switcher in Profile → Appearance. No code changes to components are required.

3.14 Object storage

DecisionChoiceAlternatives consideredReason
File / attachment storageAWS S3Cloudflare R2, Tigris (Fly.io native), Supabase StorageS3 is the industry standard with mature SDKs, fine-grained IAM, pre-signed URL support, and well-understood pricing. Tigris was considered for tighter Fly.io integration but S3 gives more portability.

Usage:

  • Bucket structure: One bucket per environment (inboxops-prod, inboxops-staging); objects are keyed by {tenant_id}/{message_id}/{filename} to enforce tenant isolation at the path level.
  • Access: No public bucket access. All downloads are via pre-signed URLs with short expiry (15 minutes).
  • Upload: Attachments are uploaded server-side (worker receives from IMAP/API, uploads to S3); outbound attachments are uploaded by the web app via a signed upload URL or directly from the API.
  • Credentials: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY stored as Fly.io secrets; IAM policy scoped to the specific bucket(s) only.

4. Architecture

4.1 Monorepo structure

inboxops/
├── apps/
│ ├── web/ # Next.js App Router — marketing site + SaaS app (single deployment)
│ │ ├── app/
│ │ │ ├── (marketing)/ # Route group: public marketing pages (no auth middleware)
│ │ │ │ ├── page.tsx # Landing page (/)
│ │ │ │ ├── pricing/ # /pricing
│ │ │ │ ├── contact/ # /contact
│ │ │ │ ├── legal/
│ │ │ │ │ ├── terms/ # /legal/terms (AGB)
│ │ │ │ │ ├── privacy/ # /legal/privacy (Datenschutz)
│ │ │ │ │ └── impressum/ # /legal/impressum
│ │ │ │ └── blog/ # /blog (future; stubbed)
│ │ │ ├── (auth)/ # Route group: login, signup, reset, invite
│ │ │ ├── app/[slug]/ # Route group: authenticated SaaS app
│ │ │ └── api/ # API routes (tRPC, REST, webhooks, Stripe, Inngest)
│ └── worker/ # Inngest worker (email sync, send, notifications, webhooks)
├── packages/
│ ├── api/ # tRPC router, procedures, shared validation (Zod)
│ ├── db/ # Drizzle schema, migrations, client
│ ├── auth/ # Lucia config, session, tenant context helpers
│ ├── email/ # IMAP/Gmail/Graph clients, send pipeline, MIME construction, token encryption
│ ├── billing/ # Stripe client, createCheckoutSession, createPortalSession, webhook handler, entitlement sync
│ ├── notifications/ # react-email templates, Postmark delivery, in-app notification helpers
│ ├── config/ # Env validation (zod), feature flags, tenant config loader
│ └── ui/ # shadcn-based shared components
└── docs/ # Internal documentation
  • apps/web: Serves the Next.js app; runs on Fly.io; calls tRPC and optionally runs Inngest dev server.
  • apps/worker: Registers Inngest functions (email fetch, send, Postmark, webhooks, etc.); runs as a separate process or same Fly machine with different entrypoint.
  • packages/*: Shared code only; no direct deployment. Used by web and worker.

4.2 Multi-tenancy model

  • Row-level tenancy: Every tenant-scoped table has a tenant_id column. All queries that read or write tenant data include tenant_id in the predicate.
  • PostgreSQL RLS: Row-Level Security policies are defined on tenant-scoped tables so that, when the session variable app.current_tenant_id is set, only rows for that tenant are visible. This is a safety net in addition to application-level scoping.
  • No schema-per-tenant: A single schema is used; we do not create one schema per tenant. This keeps migrations and connection handling simple.

4.3 Tenant context resolution

Routing decision: path-based tenant routing on the main domain

The entire product — marketing site and SaaS app — lives on inboxops.app. There is no separate app. subdomain. The tenant slug is the first segment under /app/:

https://inboxops.app/                      → landing page
https://inboxops.app/pricing → pricing page
https://inboxops.app/app/acme/tickets → SaaS app (tenant: acme)
https://inboxops.app/app/acme/tickets/[id]

Staging runs on staging.inboxops.app with the identical structure and its own Fly.io app + Neon branch.

Why path routing over subdomain routing:

ConcernPath routingSubdomain routing
DNS setupSingle A recordWildcard DNS (*.inboxops.app) required
SSL/TLSSingle certWildcard cert (or per-tenant cert via Let's Encrypt)
OAuth redirect URIsOne URI per providerOne URI per provider (slug not in redirect path)
Cookie scopeStandard; no cross-domain complexityMust set Domain: .inboxops.app — shares cookies across tenants
Next.js middlewareRead slug from pathnameRead slug from Host header
Local developmentWorks with localhost out of the boxRequires /etc/hosts entries or tunnel
ComplexitySimplerMore moving parts, higher ops cost at start

Route structure update:

All authenticated app routes are nested under /app/[slug]/:

/                           → redirect to /login
/login → sign in (global, no tenant)
/signup → create account + tenant (global)
/reset-password → password reset (global)
/reset-password/[token] → set new password (global)
/invite/[token] → accept invitation (global; resolves tenant from token)
/app/[slug]/ → redirect to /app/[slug]/tickets
/app/[slug]/onboarding → setup wizard
/app/[slug]/tickets → ticket list
/app/[slug]/tickets/[id] → ticket detail
/app/[slug]/inboxes/[id] → inbox-filtered ticket list
/app/[slug]/reports → reports
/app/[slug]/settings/... → settings pages
/app/[slug]/profile → user profile
/app/[slug]/locked → account locked

Tenant resolution flow:

  1. Next.js middleware matches /app/[slug]/**.
  2. Extract slug from pathname (e.g. acme from /app/acme/tickets).
  3. Look up tenant by slug in DB → if not found, show 404/tenant-not-found page.
  4. Validate session (Lucia) → if no session, redirect to /login?redirect=<current-url>.
  5. Verify tenant_memberships row exists for (tenantId, userId) → if not, redirect to /login.
  6. Continue with onboarding/lock checks as defined in Section 4.4.

Tenant-aware links: All in-app navigation uses a useTenantPath(path) helper that prepends /app/[slug] automatically. Links are never hardcoded with the slug.

Post-login / post-signup redirect (tenant selection): When the user signs in or signs up and is redirected to the app, the target must be /app/[slug]/tickets. The slug is chosen as: (1) last-used tenant (e.g. from session or cookie last_tenant_slug), or (2) if none, the first tenant the user belongs to (by tenant_memberships). If the user has no tenants (e.g. invite-only and no invite accepted yet), redirect to a "No workspace" or invite-pending page instead of /app/.... Same logic applies after invite accept: redirect to the invited tenant's slug.

For background jobs (e.g. "sync inbox X"), tenant_id and inbox_id are passed as payload; the worker loads tenant and inbox and performs work in that context.

4.4 Middleware stack

The Next.js middleware file (middleware.ts) runs on every matching request before the page or API handler. The following rules apply in order:

Route groups and what runs on them:

/app/[slug]/**   → Slug extract → Tenant resolve → Auth check → Onboarding redirect → Trial/lock check
/api/trpc/** → Auth check → Tenant resolve (slug from tRPC input or header)
/api/v1/** → API key auth → Tenant resolve (from key) → Rate limit check
/api/inngest → Inngest signing key verification only (no user session)
/api/stripe → Stripe signature verification only (no user session)
/api/webhooks/** → Provider-specific signature/token verification (Gmail Pub/Sub JWT, Graph validation)
/api/auth/** → Rate limit by IP only (no session required)
/login /signup → Redirect to /app/[slug]/tickets if already authenticated (slug from last session)

/app/[slug]/** middleware sequence:

  1. Extract slug from pathname → look up tenants by slug → if not found, return 404
  2. Read session cookie → validate with Lucia → if invalid, redirect to /login?redirect=<url>
  3. Verify tenant_memberships row for (tenantId, userId) → if not, redirect to /login
  4. Check onboarding_completed_at → if null and not on /app/[slug]/onboarding, redirect there
  5. Check subscription status → if locked, redirect to /app/[slug]/locked (except /locked itself)
  6. Set tenantId, userId, role in request headers for Server Components and tRPC context

/api/v1/** middleware sequence:

  1. Extract Authorization: Bearer <key> → hash → look up api_keys by key_hash
  2. If not found or revoked_at is set → 401 Unauthorized
  3. Load tenant from api_keys.tenant_id
  4. Check tenant_entitlements for public_api → if not entitled → 403 Forbidden
  5. Check rate limit (DB sliding window) → if exceeded → 429 Too Many Requests with Retry-After
  6. Attach tenantId to request context

/api/auth/** middleware:

  • Rate limit: max 10 requests / minute per IP. Enforced in middleware before hitting the handler.

4.5 Multi-tenant isolation in workers

Background workers process data for multiple tenants. The following rules ensure isolation:

  • Job payload always includes tenant_id: No job should look up data without a scoped tenant context.
  • No cross-tenant data access: A worker function for tenant A never queries or modifies data for tenant B; enforced by always including tenant_id in every DB query.
  • Rate limits per tenant: Each tenant's email sync and send jobs are rate-limited independently (see Section 4.8 — Rate Limits).
  • Token and credential handling: Inbox credentials are loaded fresh per job from the DB; never cached globally across tenants in memory.
  • Failure isolation: A failing job for one tenant (e.g. IMAP timeout) does not block or delay jobs for other tenants; Inngest functions are per-inbox/per-tenant.

4.6 API design

  • tRPC: Used by the Next.js app only. Router is organized by domain (e.g. tenant, inbox, ticket, message, user, billing). Each procedure that touches tenant data receives tenantId from context and passes it to the DB layer. Public routes (sign-in, sign-up) are outside tenant context.
  • REST: Used for webhooks (we send) and for the public API (we receive). Webhooks: POST to customer URL with JSON body and signature header. Public API: REST over HTTP with API key or Bearer token; tenant is derived from the key; base path e.g. /api/v1/.

4.7 Email pipeline (end-to-end)

  1. Inbound

    • Worker runs on a schedule or is triggered (e.g. Gmail push): "fetch new messages for inbox I".
    • Worker loads inbox and tenant; uses stored credentials/tokens to fetch (IMAP IDLE/poll, Gmail API, or Graph).
    • For each new message: check for spam/bounce/auto-reply headers (see edge case handling); skip or handle accordingly.
    • Parse headers and body; resolve thread (message-id, in-reply-to, references or provider thread id).
    • Deduplication: check message_id_header — skip if already stored.
    • If no matching thread: create ticket (insert tickets, then messages with first message); optionally enqueue "send auto-response" job (checking OOO loop prevention headers first).
    • If matching thread: find ticket; insert new message; update ticket updated_at.
    • Persist attachment metadata and upload bodies to object storage if applicable.
    • If webhooks are configured for "ticket.created" or "message.received", enqueue webhook jobs.
    • If notifications are configured (e.g. new reply on assigned ticket), enqueue notification jobs.
  2. Outbound

    • User submits reply in UI → tRPC mutation.
    • Server validates (ticket exists, user can reply), inserts messages row (outbound, send_status: 'pending'), enqueues Inngest "send email" job. There is no separate "draft" state — unsent replies are stored as pending until the worker processes them.
    • Worker loads ticket, thread, inbox, and message; builds MIME with In-Reply-To/References; sends via inbox connection (SMTP or Gmail/Graph); on success updates message as sent and ticket updated_at; on failure marks message failed and notifies agent.

4.8 Async job architecture

  • Inngest runs all background work. Functions are registered in worker (or in web if single process).
  • Email fetch: One function per inbox (or batched by tenant); runs on schedule or via push; reads from provider and creates/updates tickets and messages.
  • Send reply: Triggered by tRPC after reply submit; one job per message; uses inbox connection to send.
  • System notifications: Triggered by events (e.g. ticket assigned, mention); function calls Postmark to send to agent email; inserts in-app notification row.
  • Webhooks: Triggered by events; function POSTs to customer URL with retries.
  • Stripe webhooks: Handled by HTTP endpoint; validate signature, enqueue or process synchronously to update subscription and entitlements.
  • SLA checks: Cron function runs periodically; queries tickets approaching or breaching SLA; enqueues notification jobs for breached tickets.
  • Trial lifecycle: Cron function runs daily; checks tenants.trial_ends_at; sends day-25 reminder, day-30 expiry notification, day-37 lock warning; updates subscription status accordingly.
  • Gmail watch renewal: Cron function runs daily; queries inboxes where gmail_watch_expires_at < now() + 24h; calls users.watch() for each and updates gmail_watch_expires_at.
  • Graph subscription renewal: Cron function runs every 12 hours; queries inboxes where graph_subscription_expires_at < now() + 24h; calls PATCH /subscriptions/{id} for each and updates graph_subscription_expires_at.
  • Webhook delivery pruning: Cron function runs weekly; deletes webhook_deliveries rows older than 30 days or beyond the 100-per-endpoint cap.
  • Audit log pruning: Cron function runs weekly; deletes audit_logs rows older than the tenant's retention period (30d / 90d / 1y / custom based on plan).

Heavy or per-tenant work stays in workers so the web app stays fast and scalable.

4.9 Rate limits

Rate limits protect both the system and external providers from abuse or runaway tenants.

ScopeLimitEnforcement
Email fetch per inboxMax N syncs per hour (e.g. 60)Inngest concurrency + rate limit
Email send per tenantMax N outbound messages per minuteInngest rate limit
Public API per tenante.g. 100 req/minDB-based counter (api_rate_limits table or Neon; increment + TTL via timestamp window)
Webhook deliveryExponential backoff; max 10 attempts (e.g. 1m, 2m, 4m, 8m, 16m, 30m, 1h, 2h, 4h, 8h)Inngest retry config
tRPC mutations (sensitive)Auth, invite, password reset: limited per IP or userMiddleware

Limits are configurable per tenant (e.g. higher limits for paid tiers).

Note on Redis: Redis is intentionally not part of the stack. Rate limiting uses a DB-based sliding window (Neon/Postgres). If rate limiting performance becomes a bottleneck at scale, Redis can be introduced then — but it adds operational complexity that is not justified at the current stage.

4.10 Ingestion concurrency and idempotency

Email ingestion runs as background jobs and can encounter parallel execution or retries. The following rules ensure correctness:

Deduplication

  • messages.message_id_header has a unique constraint scoped per inbox (UNIQUE (inbox_id, message_id_header)).
  • Before inserting a message, the worker checks for existence. If a message with the same message_id_header already exists for that inbox, the insert is skipped (upsert with ON CONFLICT DO NOTHING).
  • This makes ingestion idempotent: running the same sync job twice produces no duplicate messages or tickets.

Parallel job protection

  • Inngest concurrency keys are set per inbox (e.g. concurrency: { key: "inbox/{inbox_id}", limit: 1 }), ensuring only one sync job runs per inbox at a time.
  • This prevents race conditions where two simultaneous jobs both see the same new messages and both try to create the same ticket.
  • Send jobs (outbound) also use a per-message concurrency key to prevent double-sending.

Ticket creation atomicity

  • Creating a ticket from a new email is a single DB transaction: insert ticket, insert message, optionally insert notification rows. If any step fails, the whole transaction rolls back.
  • The job is retried by Inngest. Because message_id_header is unique, a retry after a partial failure will either re-insert successfully (if the transaction rolled back) or skip cleanly (if it already committed).

Thread matching

  • Thread matching uses message_id_header (In-Reply-To / References headers) or provider thread ID (Gmail thread id, Graph conversation id).
  • Matching is done by querying messages for a known message_id_header before inserting. If a match is found, the new message is appended to the existing ticket.
  • Ambiguous cases (e.g. no matching thread but subject starts with "Re:") fall back to creating a new ticket — avoiding false thread merges.

4.11 Real-time update strategy

Decision: Client-side polling via React Query

All live data updates (new messages in thread, new tickets in list, notification count) use simple HTTP polling via React Query's refetchInterval. No WebSockets, no Server-Sent Events, no long polling.

Rationale:

OptionWhy rejected
WebSocketsFly.io connections are machine-pinned; horizontal scaling requires sticky sessions or a shared pub/sub layer (Redis/Ably). Adds significant operational complexity for marginal UX gain.
Server-Sent EventsSame machine-pinning problem as WebSockets. Each open SSE connection counts against the machine's connection limit.
Long pollingOpen connections still tied to one machine instance; timeout and reconnect handling adds complexity; no meaningful latency advantage over short-interval polling at this scale.
Simple pollingStateless HTTP requests route to any Fly.io machine. Scales horizontally with zero coordination. React Query handles it natively. Sufficient latency for support tooling.

Polling intervals by context:

ContextIntervalTrigger
Ticket thread (ticket detail open)5 secondsrefetchInterval: 5000 on message list query
Ticket list15 secondsrefetchInterval: 15000; also refetches on window focus
Notification bell count30 secondsrefetchInterval: 30000; also refetches on window focus
Inbox sync status (settings page)10 secondsOnly active when settings page is open

Implementation: React Query's useQuery with refetchInterval and refetchOnWindowFocus: true. No extra libraries. Polling stops automatically when the component unmounts (user navigates away). Background tabs do not poll — React Query respects refetchIntervalInBackground: false by default.

Future: If low-latency push becomes a product requirement (e.g. live agent collaboration), SSE or WebSockets can be added to the worker with Fly.io's session affinity (fly-prefer-region header) or a lightweight managed pub/sub layer (Ably, Soketi). The polling architecture does not need to be dismantled to add this.

4.12 Staging environment

  • Neon: One project; staging uses a branch (e.g. staging) that can be created from main or reset. Migrations run against this branch.
  • Fly.io: One staging app (e.g. inboxops-staging) with its own config; DATABASE_URL points to the Neon staging branch. Same image as production or build from same repo with staging env.
  • Deploy: GitHub Actions deploys to staging on push to staging branch; production deploys from main or tags. Secrets and env vars are separate for staging.
  • Staging data: Staging uses empty or seed data only (e.g. test tenants created via signup or seed script). No production data is copied to staging. For realistic testing, use seed scripts or manual test data. Anonymized production copy is out of scope at launch.

5. Data Model

Key entities and their main fields. Enough to derive a full Drizzle schema. All IDs are UUIDs unless noted. Tenant-scoped tables include tenant_id.

updated_at strategy

All tables with an updated_at column use a PostgreSQL trigger to set updated_at = now() automatically on every UPDATE. This is defined once as a reusable trigger function and applied to each table in the migration:

CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Applied to each table, e.g.:
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON tickets
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

Application code never sets updated_at explicitly. Drizzle migrations include the trigger creation for every affected table. Tables without an updated_at column (e.g. audit_logs, notifications, sessions) are append-only and do not need it.

5.1 Tenant

FieldTypeNotes
iduuidPK
namestringDisplay name
slugstringUnique, URL-safe; used for path-based tenant resolution (e.g. /app/acme/tickets)
onboarding_completed_attimestamp nullableNull = show onboarding wizard
trial_ends_attimestamp nullableSet to now() + 30 days on signup; null after subscription starts
ticket_number_seqintAuto-incrementing counter for human ticket IDs; incremented on each ticket insert
created_attimestamp
updated_attimestamp

5.2 User

FieldTypeNotes
iduuidPK
namestringFull name; shown in assignee dropdowns, audit log, @mentions, notifications
emailstringUnique, lowercase
email_verifiedboolean
password_hashstring nullableNull if SSO-only
avatar_urlstring nullableOptional profile image URL; if null, UI renders initials avatar from name
created_attimestamp
updated_attimestamp

Users are global; tenant association is through tenant_memberships.

5.3 TenantMembership

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
user_iduuidFK → users
roleenum'admin' | 'agent' | 'viewer'
created_attimestamp
updated_attimestamp

Unique (tenant_id, user_id).

5.4 Inbox

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
namestringInternal display name (shown in UI only)
email_addressstringPrimary sending/receiving address
display_namestring nullableName shown in email From header e.g. "Acme Support"; if null, email_address is used alone
connection_typeenum'imap_smtp' | 'gmail' | 'microsoft'
credentials_encryptedblob/textEncrypted credentials or token set
last_errorstring nullableLast sync/send error
connected_attimestamp nullable
archived_attimestamp nullable
deleted_attimestamp nullableSoft delete; excluded from all active queries
gmail_history_idstring nullableLast processed Gmail history ID (Gmail only)
gmail_watch_expires_attimestamp nullableGmail Pub/Sub watch expiry; renewed daily by cron (Gmail only)
graph_delta_tokenstring nullableDelta token for incremental Graph sync (Microsoft only)
graph_subscription_idstring nullableGraph change notification subscription ID (Microsoft only)
graph_subscription_expires_attimestamp nullableGraph subscription expiry; renewed every 12h by cron (Microsoft only)
imap_last_uidbigint nullableLast seen IMAP UID (IMAP only)
auto_response_enabledbooleanDefault false; sends auto-reply on new ticket creation
auto_response_bodytext nullableTemplate body; supports {{ticket_number}}, {{org_name}}, {{inbox_name}}, {{sender_name}}
created_attimestamp
updated_attimestamp

Outbound From header construction:

  • If display_name is set: From: Acme Support <support@acme.com>
  • If display_name is null: From: support@acme.com
  • The agent's personal name is not included in the From header — replies come from the inbox identity, not from individual agents. This is intentional: it keeps the customer-facing identity consistent regardless of which agent responds.

Aliases: inbox_aliases(id, inbox_id, email, created_at). Aliases are additional addresses the inbox can receive on; replies always go from email_address.

5.5 Ticket

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
inbox_iduuidFK → inboxes
ticket_numberintHuman-readable ID, sequential per tenant (e.g. 1042); set from tenants.ticket_number_seq on insert
subjectstringFrom first message
statusenumopen, in_progress, waiting, resolved, closed
priorityenum'low' | 'normal' | 'high' | 'urgent'; default 'normal'
assignee_iduuid nullableFK → users
first_response_attimestamp nullableWhen first outbound reply was sent
resolved_attimestamp nullableWhen status set to resolved
sla_policy_iduuid nullableFK → sla_policies
tsvtsvectorGenerated: GENERATED ALWAYS AS (to_tsvector('english', coalesce(subject, ''))) STORED; defined via raw SQL in migration (Drizzle does not support generated columns natively)
created_attimestamp
updated_attimestamp

Unique (tenant_id, ticket_number). ticket_number is assigned atomically at insert time using UPDATE tenants SET ticket_number_seq = ticket_number_seq + 1 RETURNING ticket_number_seq within the ticket creation transaction — no gaps, no races.

The ticket number is used in:

  • Email subjects: Re: [#1042] Invoice question (appended by outbound send worker if not already present). Configurability: At launch, appending the ticket number to the reply subject is global behaviour; per-tenant or per-inbox opt-out (e.g. "Do not add ticket number to subject") can be added later if requested.
  • Auto-response templates: Your request has been logged as ticket #&#123;&#123;ticket_number&#125;&#125;
  • UI display: shown in ticket header and list rows
  • Audit log references

Tags: ticket_tags(ticket_id, tag_id, created_at) — see Section 5.13.

5.6 Message

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
inbox_iduuidFK → inboxes; denormalized from ticket for deduplication constraint
ticket_iduuidFK → tickets
directionenum'inbound' | 'outbound'
typeenum'email' | 'internal_note'
send_statusenum nullablenull for inbound; 'pending' | 'sending' | 'sent' | 'failed' for outbound
send_errorstring nullableError message if send_status = 'failed'
bounce_received_attimestamp nullableOutbound only: set when a bounce notification is linked to this message (see Section 2.3); UI shows "Delivery failed" / "Bounce received"
message_id_headerstring nullableRFC Message-ID for threading + dedup
in_reply_tostring nullable
from_addressstring
to_addressesstring / json
body_htmltext nullable
body_texttext nullable
tsvtsvectorGenerated: GENERATED ALWAYS AS (to_tsvector('english', coalesce(body_text, ''))) STORED; defined via raw SQL in migration
sent_attimestamp nullableFor outbound, when actually sent by worker
created_attimestamp

Unique constraint: (inbox_id, message_id_header) — enforces one stored message per Message-ID per inbox (see Section 4.10 Deduplication).

Outbound message lifecycle: pending (inserted by web on reply submit) → sending (worker picks up job) → sent (SMTP/API confirmed) or failed (error stored in send_error). Agent can retry a failed message from the UI.

Attachments: attachments(id, message_id, tenant_id, file_name, storage_key, content_type, size_bytes, created_at).

5.7 Subscription / TenantPlan

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants, unique
stripe_customer_idstring nullable
stripe_subscription_idstring nullableNull during trial
plan_idstring'trial', 'starter', 'growth', 'business', 'enterprise'
inbox_limitintFrom plan; 10 during trial (Growth equivalent)
statusenum'trialing' | 'active' | 'past_due' | 'cancelled' | 'locked'
current_period_endtimestamp nullableEnd of paid period; null during trial (use tenants.trial_ends_at)
created_attimestamp
updated_attimestamp

5.8 TenantEntitlement

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
feature_keystringe.g. 'sso', 'public_api', 'advanced_reports'
enabledboolean
created_attimestamp
updated_attimestamp

Unique (tenant_id, feature_key).

5.9 Notification

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
user_iduuidFK → users
typestringe.g. 'ticket_assigned', 'sla_breach'
payloadjsonbContextual data (ticket_id, etc.)
read_attimestamp nullable
created_attimestamp

5.10 SlaPolicy

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
inbox_iduuid nullableFK → inboxes; null = tenant-wide default
namestringe.g. "Standard SLA"
first_response_hoursintTarget first response time
resolution_hoursintTarget resolution time
business_hours_onlybooleanWhether to count only business hours
created_attimestamp
updated_attimestamp

5.11 AuditLog

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
user_iduuid nullableFK → users; null for system actions
actionstringe.g. 'user.invited', 'inbox.connected'
resource_typestringe.g. 'inbox', 'user', 'ticket'
resource_iduuid nullable
metadatajsonbAdditional context
created_attimestamp

5.12 Session

Managed by Lucia. Stored in DB.

FieldTypeNotes
idstringPK; random token (not UUID)
user_iduuidFK → users
expires_attimestampSliding expiry; max 90 days
created_attimestamp

One row per active session. Lucia handles creation and validation. Sessions are invalidated on sign-out, password change, or explicit revocation (e.g. remove from tenant). Indexed on user_id for bulk invalidation.

5.13 Tag

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
namestringe.g. 'billing', 'bug'
colorstringHex color code e.g. '#ef4444'
created_attimestamp
updated_attimestamp

Unique (tenant_id, name). Junction: ticket_tags(ticket_id, tag_id, created_at). Unique (ticket_id, tag_id).

5.14 Invitation

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
emailstringInvitee email address
roleenum'admin' | 'agent' | 'viewer'
tokenstringUnique, random; used in invite link
expires_attimestampe.g. now() + 7 days
accepted_attimestamp nullableSet when invite is accepted
invited_by_user_iduuidFK → users
created_attimestamp

5.15 Snippet (Canned Response)

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
namestringDisplay name (e.g. "Welcome response")
bodytextTemplate body (plain text or HTML)
created_by_user_iduuidFK → users
created_attimestamp
updated_attimestamp

5.16 WebhookEndpoint

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
urlstringCustomer-provided HTTPS endpoint
secretstringShared secret for HMAC signature verification (stored encrypted)
eventsstring[]Subscribed event types e.g. ['ticket.created', 'ticket.closed']
enabledbooleanCan be toggled without deleting
created_attimestamp
updated_attimestamp

5.17 WebhookDelivery

Delivery log per webhook endpoint. Used for the "last delivery status" UI in settings and for debugging failed deliveries.

FieldTypeNotes
iduuidPK
webhook_endpoint_iduuidFK → webhook_endpoints
tenant_iduuidFK → tenants
eventstringe.g. 'ticket.created'
event_idstringIdempotency key from the event payload
payloadjsonbFull JSON payload sent
response_statusint nullableHTTP status code from customer endpoint; null if request failed to send
response_bodytext nullableFirst 1000 chars of response body
attemptintAttempt number (1 = first try)
succeededbooleanTrue if response_status is 2xx
delivered_attimestamp nullableWhen request was sent
created_attimestamp

Index: (webhook_endpoint_id, created_at DESC) — for loading delivery history per endpoint. Retention: keep last 100 deliveries per endpoint (or 30 days, whichever comes first); older rows pruned by cron.

5.18 ApiKey

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
namestringHuman label (e.g. "Zapier integration")
key_hashstringSHA-256 hash of the key; never store plaintext
key_prefixstringFirst 8 chars of key shown in UI for identification (e.g. ixk_a1b2)
last_used_attimestamp nullableUpdated on each API request
revoked_attimestamp nullableSet on revoke; key is invalid when not null
created_by_user_iduuidFK → users
created_attimestamp

5.19 FeatureFlag

FieldTypeNotes
iduuidPK
keystringUnique flag identifier e.g. 'new_ticket_ui'
enabled_globallybooleanDefault value for all tenants
descriptionstring nullableInternal note on what this flag controls
created_attimestamp
updated_attimestamp

Per-tenant overrides: feature_flag_overrides(id, flag_key, tenant_id, enabled, created_at). Tenant override takes precedence over global value.

5.20 NotificationPreference

FieldTypeNotes
iduuidPK
user_iduuidFK → users
tenant_iduuidFK → tenants; preferences are per-tenant
event_typestringe.g. 'ticket_assigned', 'sla_breach'
email_enabledbooleanDefault true
in_app_enabledbooleanDefault true
created_attimestamp
updated_attimestamp

Unique (user_id, tenant_id, event_type). If no row exists for a user+tenant+event, defaults apply (both channels on).

5.21 BusinessHours

FieldTypeNotes
iduuidPK
tenant_iduuidFK → tenants
timezonestringIANA timezone e.g. 'Europe/Berlin'
monday_starttime nullablenull = closed all day
monday_endtime nullable
tuesday_starttime nullable
tuesday_endtime nullable
wednesday_starttime nullable
wednesday_endtime nullable
thursday_starttime nullable
thursday_endtime nullable
friday_starttime nullable
friday_endtime nullable
saturday_starttime nullable
saturday_endtime nullable
sunday_starttime nullable
sunday_endtime nullable
created_attimestamp
updated_attimestamp

One row per tenant (or one per SLA policy if policies need different hours). SLA clock calculations use these times when sla_policies.business_hours_only = true.

Default when no row exists: If a tenant has no business_hours row, SLA calculations that use business hours treat the tenant as "no business hours configured" — either skip SLA clock for those policies or use a sensible default (e.g. Mon–Fri 09:00–17:00 in a configured default timezone such as Europe/Berlin) until the admin configures Business Hours in Settings → SLA. Implementation choice: create a default row on tenant creation, or compute default in code when row is missing.

5.21b Other entities (Attachment, ContactSubmission, InboxAlias, FeatureFlagOverride)

These tables are referenced elsewhere; full field lists are given here for the data model.

Attachment (see Section 5.6 Message):

FieldTypeNotes
iduuidPK
message_iduuidFK → messages
tenant_iduuidFK → tenants
file_namestringOriginal filename
storage_keystringS3 object key (tenant-prefixed path)
content_typestringMIME type
size_bytesint
created_attimestamp

ContactSubmission (see Section 2.13 Marketing website):

FieldTypeNotes
iduuidPK
namestring
emailstring
companystring nullable
subjectstringDropdown value
messagetext
ip_addressstring nullableFor rate limiting; hashed or truncated for GDPR
created_attimestamp

InboxAlias (see Section 5.4 Inbox):

FieldTypeNotes
iduuidPK
inbox_iduuidFK → inboxes
emailstringAdditional address that maps to this inbox
created_attimestamp

Unique (inbox_id, email) per tenant scope as needed.

FeatureFlagOverride (see Section 5.19 FeatureFlag):

FieldTypeNotes
iduuidPK
flag_keystringFK or reference to feature_flags.key
tenant_iduuidFK → tenants
enabledbooleanOverride for this tenant
created_attimestamp

Unique (flag_key, tenant_id). Tenant override takes precedence over feature_flags.enabled_globally.


5.22 Database Indexes

Critical indexes required for query performance. All tenant-scoped queries begin with a tenant_id predicate — every major table must have this covered. Drizzle migrations should include these explicitly.

Primary lookup indexes

TableIndexTypePurpose
tenants(slug)UniqueTenant resolution from path (slug)
users(email)UniqueLogin lookup
tenant_memberships(tenant_id, user_id)UniqueMembership check
tenant_memberships(user_id)IndexLoad all tenants for a user
sessions(user_id)IndexInvalidate all sessions for a user
invitations(token)UniqueToken lookup on invite accept
invitations(tenant_id, email)IndexCheck for existing pending invite

Ticket queries (most frequent, most critical)

TableIndexTypePurpose
tickets(tenant_id, status, created_at DESC)IndexDefault ticket list (open tickets, newest first)
tickets(tenant_id, inbox_id, status)IndexPer-inbox ticket list
tickets(tenant_id, assignee_id, status)IndexMy tickets filter
tickets(tenant_id, ticket_number)UniqueHuman ID lookup
tickets(tenant_id, updated_at DESC)IndexSort by last activity
tickets(sla_policy_id) WHERE status NOT IN ('resolved','closed')Partial indexSLA cron job: open tickets with policy
ticket_tags(ticket_id)IndexLoad tags for a ticket
ticket_tags(tag_id)IndexFilter tickets by tag

Message queries

TableIndexTypePurpose
messages(ticket_id, created_at ASC)IndexLoad thread in order
messages(inbox_id, message_id_header)UniqueDeduplication on ingest
messages(tenant_id, send_status) WHERE direction = 'outbound'Partial indexFind pending/failed outbound messages
attachments(message_id)IndexLoad attachments for a message
TableIndexTypePurpose
notifications(user_id, tenant_id, read_at)IndexUnread count + notification list
notifications(tenant_id, created_at DESC)IndexNotification history
tickets(tenant_id, tsv) using GINGINFull-text search (tsv is a generated tsvector column from subject)
messages(tenant_id, tsv) using GINGINFull-text search on body_text

Audit and billing

TableIndexTypePurpose
audit_logs(tenant_id, created_at DESC)IndexAudit log view
subscriptions(tenant_id)UniquePlan lookup
tenant_entitlements(tenant_id, feature_key)UniqueEntitlement check
api_keys(key_hash)UniqueAPI key authentication
api_keys(tenant_id)IndexList keys for tenant
webhook_endpoints(tenant_id)IndexLoad endpoints for event fan-out

6. UX Flows

6.1 Application and site navigation structure

Marketing (public, no auth, SSG):

inboxops.app/                              → landing page
inboxops.app/pricing → pricing + plan comparison
inboxops.app/contact → contact form
inboxops.app/legal/terms → Terms of Service (AGB)
inboxops.app/legal/privacy → Privacy Policy (Datenschutz)
inboxops.app/legal/impressum → Imprint (Impressum)
inboxops.app/blog → blog (stub at launch)
inboxops.app/sitemap.xml → auto-generated sitemap

Auth (public, SSR):

inboxops.app/login                         → sign in
inboxops.app/signup → create account + tenant
inboxops.app/reset-password → request password reset
inboxops.app/reset-password/[token] → set new password
inboxops.app/invite/[token] → accept invitation (tenant resolved from token)

No workspace (authenticated user with no tenant memberships): When the user is logged in but has no tenant (e.g. invite-only signup with no accepted invite yet), they cannot be sent to /app/[slug]/.... Redirect to inboxops.app/no-workspace, which shows: "You don't have access to any workspace yet" and links to check email for invitations or to create an organization. After accepting an invite or creating a tenant, they are redirected to /app/[slug]/tickets as usual.

SaaS App (authenticated, SSR/RSC):

inboxops.app/app/[slug]/                   → redirect to /app/[slug]/tickets
inboxops.app/app/[slug]/onboarding ← setup wizard
inboxops.app/app/[slug]/tickets ← ticket list
inboxops.app/app/[slug]/tickets/[id] ← ticket detail + thread
inboxops.app/app/[slug]/inboxes/[id] ← inbox-filtered ticket list
inboxops.app/app/[slug]/reports ← reporting
inboxops.app/app/[slug]/settings/general
inboxops.app/app/[slug]/settings/inboxes
inboxops.app/app/[slug]/settings/inboxes/new
inboxops.app/app/[slug]/settings/inboxes/[id]
inboxops.app/app/[slug]/settings/team
inboxops.app/app/[slug]/settings/billing
inboxops.app/app/[slug]/settings/sla
inboxops.app/app/[slug]/settings/snippets
inboxops.app/app/[slug]/settings/webhooks ← Business+
inboxops.app/app/[slug]/settings/api ← Business+
inboxops.app/app/[slug]/settings/sso ← Enterprise
inboxops.app/app/[slug]/settings/audit
inboxops.app/app/[slug]/profile
inboxops.app/app/[slug]/locked ← account locked

All in-app links use useTenantPath(path) which prepends /app/[slug]. Slugs are never hardcoded in components.

Marketing middleware excludes /app/** and /api/** from auth checks. The (marketing) route group in Next.js has no auth layout wrapper.

6.2 Onboarding wizard (step by step)

The wizard is shown to any tenant where onboarding_completed_at is null. It is dismissible but persists until completed.

StepContentDone when
1. WelcomeOrg name confirmation, slug previewUser confirms org details
2. Connect your first inboxChoose connection type (Gmail / Microsoft / IMAP-SMTP) → OAuth or credential form → test connectionInbox is connected and verified
3. Invite your teamEnter email addresses and roles to invite (optional, skippable)Invites sent or step skipped
4. DoneSummary of what was set up; CTA to "Go to inbox"User clicks CTA
  • Progress is shown as a step indicator (e.g. 1 / 4).
  • Each step validates before advancing; errors are shown inline.
  • Completing step 4 sets onboarding_completed_at on the tenant.
  • If the user exits mid-wizard, the next visit resumes at the last incomplete step.

6.3 Inbox connect wizard (detailed)

Triggered from onboarding or from Settings → Inboxes → Add inbox.

  1. Choose connection type: Gmail / Microsoft 365 / IMAP-SMTP
  2. Gmail / Microsoft: Show what permissions will be requested → "Connect with Google / Microsoft" button → OAuth redirect → callback → tokens stored → inbox marked connected.
  3. IMAP/SMTP: Form with IMAP host, port, security, username, password; SMTP host, port, security, username, password. "Test connection" button validates before save.
  4. Confirmation: Show connected inbox name and email address. Option to continue to ticket list or add another inbox.

Error handling: If OAuth fails or IMAP test fails, show a clear message and allow the user to retry or change settings. Do not leave the inbox in an ambiguous state.

6.4 Ticket list layout

┌─────────────────────────────────────────────────────────┐
│ [Sidebar: nav, inbox list] │ [Ticket list] │
│ │ ┌─ Filters ──────────┐ │
│ All Tickets │ │ Status ▾ Assignee ▾│ │
│ ├ Inbox A │ └────────────────────┘ │
│ ├ Inbox B │ ┌─ Ticket row ───────┐ │
│ └ Inbox C │ │ Subject │ │
│ │ │ Inbox · Status · Age│ │
│ │ └────────────────────┘ │
│ │ ┌─ Ticket row ───────┐ │
│ │ │ ... │ │
└─────────────────────────────────────────────────────────┘
  • Ticket rows show: subject, inbox name, status badge, assignee avatar, last activity time.
  • Clicking a row opens the ticket detail (side panel or full page depending on viewport).
  • Filters are persistent in URL params.
  • Bulk actions (assign, change status) on selected tickets.

6.5 Ticket detail layout

┌─────────────────────────────────────────────────────────┐
│ ← Back to list │
│ Subject: Re: Invoice question │
│ Inbox: support@acme.com │ Status: Open ▾ │ Assign ▾ │
├──────────────────────────┬──────────────────────────────┤
│ Thread (scrollable) │ Ticket info / sidebar │
│ │ Tags, SLA status, created │
│ [Inbound message] │ Internal notes │
│ [Outbound reply] │ │
│ [Internal note] │ │
│ [Inbound message] │ │
│ │ │
├──────────────────────────┴──────────────────────────────┤
│ Reply composer (always visible, sticky bottom) │
│ [Body textarea] [Attach] [Snippets] [Send ▸] │
└─────────────────────────────────────────────────────────┘
  • Internal notes are clearly distinguished (different background color, "Internal" label).
  • Reply composer is always visible and focused on page load.
  • Status and assignee are changeable inline from the header.
  • SLA countdown shown in sidebar when a policy is active.

6.6 Reply composer — Rich Text (WYSIWYG)

The reply composer uses a rich text / WYSIWYG editor for both outbound replies and internal notes. Plain text is not sufficient — agents need basic formatting, links, and inline images for professional replies.

Chosen library: Tiptap (headless, built on ProseMirror, React-compatible, works with Tailwind/shadcn)

Alternatives considered: Quill (legacy, not well-maintained), Slate.js (too low-level), TinyMCE (heavy, proprietary). Tiptap is the right balance of control, extensibility, and bundle size.

Supported formatting:

FeatureIncluded
Bold, Italic, Underline
Bullet list, Ordered list
Blockquote (for quoting previous email)
Hyperlinks
Inline code
Attachments (file upload trigger)
Image inline❌ (not in initial build; attachments only)
Tables❌ (out of scope)
Font size / color❌ (out of scope; keeps emails clean)

Output: HTML (body_html). A plain text fallback (body_text) is generated automatically by stripping HTML tags before storing and sending — used for email clients that prefer plain text and for FTS indexing.

Email sending: The HTML body is sent as text/html part with a text/plain fallback in the MIME structure. In-Reply-To and References headers are set for correct threading.

Inbound email rendering (HTML safety): Incoming emails in HTML format are rendered in the thread view but sanitized first:

  • HTML is sanitized via DOMPurify before rendering — removes scripts, event handlers, and dangerous attributes.
  • External images are blocked by default (replaced with a "Show images" toggle per message) to prevent tracking pixels and SSRF.
  • Rendered in a sandboxed <iframe> or scoped container to prevent CSS bleed into the app UI.
  • Plain text emails are rendered as <pre> with whitespace preserved.

@mention in internal notes:

  • Typing @ in an internal note composer triggers an autocomplete dropdown of agents in the tenant (fetched via tRPC).
  • Selecting a name inserts a mention node (Tiptap extension) with the user's id and display name.
  • On save, the server parses mention nodes from the stored HTML/JSON, extracts mentioned user_ids, and enqueues notification jobs for each.
  • Mentions only work in internal notes (not in outbound replies — customers don't see agents' names in mentions).
  • The mention node renders as @Name with a highlight in the UI; clicking it does nothing (no profile pages).

6.7 Attachment limits

LimitValueNotes
Max file size per attachment25 MBAligned with Gmail's limit; SMTP providers vary but 25 MB is a safe ceiling
Max attachments per message10 filesPrevents abuse; sufficient for normal support use
Max total size per message25 MBAcross all attachments combined
Allowed file typesAll types allowedNo whitelist; virus scan deferred to future
Inbound attachment storageStored as-isNo processing; original file stored in S3
Outbound attachment storageStored in S3 before sendAgent uploads file → stored in S3 → included in MIME on send

File size and count are enforced at the API layer (before S3 upload) with a clear error message returned to the UI.


6.8 Error and system state pages

Every error state has a dedicated page or UI treatment. These are not afterthoughts — they are part of the application flow.

StateURL / LocationWhat the user sees
404 Not Found/404"Page not found" with link back to tickets
500 Server Error/500"Something went wrong" with option to reload or contact support
Tenant not foundPath resolves to unknown slug"This workspace doesn't exist" with link to sign in or create org
Unauthorized (no session)Middleware redirectRedirect to /login?redirect=<original_url>
No workspace (no tenant memberships)/no-workspace"You don't have access to any workspace yet"; links to check invitations or create org; shown when authenticated user has no tenants
Forbidden (wrong tenant/role)InlineIn-page error message; not a full redirect
Account locked (post-trial, no subscription)/app/[slug]/lockedBanner + CTA to subscribe; read-only access to tickets; no reply, no sync
Trial expired bannerIn-app (persistent)Sticky top banner: "Your trial ended X days ago. Subscribe to continue." Dismissible only after subscribing
Maintenance mode/maintenanceStatic page served by Fly.io proxy; "We'll be right back". Trigger: Maintenance is enabled by switching Fly.io proxy or deployment to serve a static maintenance page (e.g. via Fly.io config or a dedicated maintenance release); the app itself does not implement a maintenance-mode toggle. Document the procedure in runbooks.
Inbox disconnected (in ticket view)Inline warning"Replies are paused — inbox disconnected. [Reconnect ▸]" shown above reply composer
Send failedInline in threadError badge on the failed outbound message; "Retry" button

6.9 Command palette

A command palette (keyboard shortcut Cmd+K / Ctrl+K) gives power users fast access to navigation, actions, and search without touching the mouse.

Library: cmdk — headless command menu for React, used internally by shadcn/ui.

Available commands:

CategoryCommands
NavigationGo to All Tickets, Go to Inbox [name], Go to Settings, Go to Reports
Ticket actions (when in ticket view)Change status → Open / In Progress / Waiting / Resolved / Closed, Assign to [agent], Add tag, Move to inbox
Global actionsNew ticket (manual creation), Search tickets, Invite team member
Settings shortcutsManage inboxes, Manage team, Billing & plan
UserToggle dark/light mode, Sign out
  • Results are filtered in real time as the user types.
  • Keyboard navigation (arrow keys, Enter, Escape) is fully supported.
  • Available commands adapt to context (ticket-specific commands only show when viewing a ticket).
  • The palette is accessible from any authenticated page.

6.10 Frontend page inventory and component structure

This section is the complete reference for every page in the application, its purpose, layout structure, key components used, and role access.

Component strategy

The app uses shadcn/ui as the primary component library (Radix UI primitives + Tailwind). Additional libraries:

LibraryPurposeUsed for
shadcn/uiCore UI primitivesButtons, inputs, selects, dialogs, dropdowns, tables, badges, toasts, forms, tabs, cards
TiptapRich text editorReply composer, internal note composer
cmdkCommand paletteCmd+K global command menu (bundled with shadcn/ui)
RechartsChartsReporting page (bar charts, line charts)
react-hook-form + ZodForms and validationAll forms (signup, settings, inbox connect, invite)
TanStack TableData tablesTicket list, team member list, audit log
date-fnsDate formattingTicket timestamps, SLA countdowns, relative time ("2 hours ago")
DOMPurifyHTML sanitizationInbound email rendering
next-themesDark/light modeTheme provider, system preference detection, FOUC prevention
sonnerToast notificationsAction feedback (ticket saved, reply sent, error, etc.)
@tanstack/react-queryClient-side data fetchingtRPC query/mutation state, background refetch, optimistic updates

All forms use react-hook-form + zod validation. All data fetching goes through tRPC + React Query. No raw fetch calls in components.


Layout shells

There are two layout shells in apps/web:

Public shell (/login, /signup, /reset-password, /invite/[token]):

  • Centered card layout, no sidebar, no nav.
  • InboxOps logo top center.
  • Light background; respects system theme preference.

App shell (/app/[slug]/**):

  • Fixed left sidebar (240px) with: logo, primary nav, inbox list, user menu at bottom.
  • Top bar: page title, search input (or Cmd+K trigger), notification bell, trial/plan banner (when relevant).
  • Main content area: scrollable, full width minus sidebar.
  • Sidebar collapses to icon-only on tablet; drawer on mobile.

Public pages

/login

  • Purpose: Sign in to existing account.
  • Layout: Public shell. Card with email + password fields, "Forgot password?" link, "Create account" link.
  • Components: Card, Input, Button, Form (react-hook-form).
  • Behaviour: On success → redirect to /app/[slug]/tickets (slug = user's last-used or first tenant; or ?redirect param if present). Show inline error on wrong credentials. Rate limit enforced server-side.
  • Role access: Unauthenticated only. Authenticated users redirected to /app/[slug]/tickets.

/signup

  • Purpose: Create new account and tenant.
  • Layout: Public shell. Card with name, email, password, org name fields.
  • Components: Card, Input, Button, Form.
  • Behaviour: Creates user + tenant + subscription (trial). Redirects to /app/[slug]/onboarding on success (slug = new tenant's slug). Sends email verification (optional, based on config).
  • Role access: Unauthenticated only.

/reset-password

  • Purpose: Request password reset email.
  • Layout: Public shell. Email input + submit.
  • Behaviour: Sends reset email via Postmark. Shows confirmation regardless of whether email exists (prevents enumeration).

/reset-password/[token]

  • Purpose: Set new password via token.
  • Layout: Public shell. New password + confirm password fields.
  • Behaviour: Validates token server-side. On success → redirect to /login. Token invalidated after use.

/invite/[token]

  • Purpose: Accept a team invitation.
  • Layout: Public shell. Shows org name and inviting user. If not logged in: create account or sign in form. If logged in: one-click accept.
  • Behaviour: Token validated server-side. On accept → tenant_membership created → redirect to /app/[slug]/tickets (slug = invited tenant). Token marked accepted_at.

No workspace

/no-workspace

  • Purpose: Shown when the user is authenticated but has no tenant memberships (e.g. invite-only signup with no accepted invite).
  • Layout: Public shell or minimal shell. Centered card.
  • Components: Card with message "You don't have access to any workspace yet", link to check email for invitations, optional "Create organization" or support contact.
  • Behaviour: User can sign out or wait for an invite to be accepted. After accepting an invite, redirect goes to /app/[slug]/tickets. No tenant context; middleware redirects here when user has no tenants (see Section 4.3, 6.1, 6.8).
  • Role access: Authenticated only; users with at least one tenant are redirected away to /app/[slug]/tickets.

Onboarding

/app/[slug]/onboarding

  • Purpose: Step-by-step setup wizard for new tenants.
  • Layout: Fullscreen wizard (no sidebar). Step indicator top. Back/Next controls.
  • Components: Stepper (custom, built with shadcn primitives), Card, Input, Button, Select (for role in step 3), connection type selector (custom cards with icons for Gmail / Microsoft / IMAP).
  • Steps: (1) Org name + slug, (2) Connect first inbox, (3) Invite team (optional), (4) Done.
  • Behaviour: Progress persisted in DB. Exit any time; resumes on next load. Completing redirects to /app/[slug]/tickets.
  • Role access: Admin only. Shown until onboarding_completed_at is set.

Core app — Tickets

/app/[slug]/tickets

  • Purpose: All tickets across all inboxes. Primary agent workspace.
  • Layout: App shell. Two-column on desktop: sidebar (nav + inbox list) + ticket list. Three-column on wide screens when a ticket is open (list + detail side-by-side).
  • Components:
    • TicketList — TanStack Table or virtualized list (react-virtual for large datasets), rows using TicketRow component.
    • TicketRow — Subject, inbox badge, status badge (Badge), priority indicator, assignee avatar (Avatar), relative timestamp.
    • FilterBarSelect components for status, assignee, inbox, priority, tag; date range picker (DateRangePicker from shadcn).
    • SearchInput — debounced input triggering FTS query.
    • BulkActionBar — appears when rows are selected; Select for status change, Combobox for assignee, Button for delete (Admin only).
    • EmptyState — illustrated message + CTA depending on filter context ("No open tickets" / "No results for your search").
  • Behaviour: Filters and sort state in URL params (shareable links). Polling or WebSocket for new tickets. Pagination or infinite scroll.
  • Role access: All roles. Viewers see tickets but cannot act.

/app/[slug]/tickets/[id]

  • Purpose: Single ticket detail — thread, reply, sidebar.
  • Layout: App shell. Full-width or split with list (desktop). Three zones: header, main (thread + composer), sidebar.
  • Components:
    • TicketHeader — Subject (editable inline, Input), inbox name, StatusSelect (inline Select), AssigneeSelect (inline Combobox with agent search), PrioritySelect.
    • MessageThread — scrollable list of MessageBubble components. Inbound vs outbound visually distinct. Internal notes with Badge "Internal".
    • MessageBubble — sender avatar, name, timestamp, HTML body (DOMPurify sanitized + show/hide images), attachment list (AttachmentList).
    • ReplyComposer — Tiptap editor, tab toggle "Reply / Internal Note", AttachmentUploader, SnippetPicker (Popover with list), Button Send.
    • TicketSidebar — Tags (TagInput multi-select Combobox), SLA countdown (SlaTimer), created/updated timestamps, ticket move (Select for inbox).
    • InboxDisconnectedBanner — shown above composer when inbox is not connected.
  • Behaviour: Reply submit → optimistic insert → tRPC mutation → Inngest job. Send failure → error badge on message + retry button. Real-time refresh via polling (5s) or WebSocket. Thread auto-scrolls to bottom on open.
  • Role access: All roles. Agents can reply and change status. Viewers read-only.

Core app — Inboxes

/app/[slug]/inboxes/[id]

  • Purpose: Ticket list filtered to a specific inbox. Same as /app/[slug]/tickets but pre-filtered.
  • Layout: Same as ticket list. Inbox name in page title.
  • Components: Reuses TicketList, FilterBar, EmptyState. Adds inbox-specific header with connection status badge.
  • Role access: All roles.

Settings

All settings pages share a sub-layout: left settings nav (tabs or vertical nav) + content area. Only Admins can access most settings pages; Agents see a subset.

/app/[slug]/settings/general

  • Purpose: Org name, slug, timezone.
  • Components: Form, Input, Select (timezone — searchable, use Combobox), Button Save. Danger Zone section with Button Delete Organization (opens AlertDialog with type-to-confirm Input).
  • Role access: Admin only.

/app/[slug]/settings/inboxes

  • Purpose: List and manage inboxes.
  • Components: InboxList — each row: inbox name, email address, connection type icon, status badge (Badge in green/yellow/red), "Settings" and "Disconnect" actions. "Add inbox" Button → opens inbox connect wizard (Dialog or /settings/inboxes/new route). Archived inboxes in a collapsed section.
  • Role access: Admin only.

/app/[slug]/settings/inboxes/new

  • Purpose: Inbox connect wizard (standalone route or Dialog).
  • Components: Stepper (same pattern as onboarding), connection type selector cards, OAuth button or IMAP form, test connection button with loading state, confirmation step.
  • Role access: Admin only.

/app/[slug]/settings/inboxes/[id]

  • Purpose: Edit inbox name, connection settings, auto-responses, aliases.
  • Components: Tabs (Tabs shadcn): "General" (name, email), "Connection" (re-auth or update credentials), "Auto-responses" (Switch + Textarea), "Aliases" (list + add).
  • Role access: Admin only.

/app/[slug]/settings/team

  • Purpose: Team members list, invite, role management.
  • Components:
    • TeamTable — TanStack Table: name, email, role (Select inline for role change), "Remove" action (AlertDialog confirm).
    • "Invite member" ButtonDialog with email Input + role Select + Send.
    • Pending invites section: list of unaccepted invites with email, role, expiry, "Resend" and "Revoke" actions.
  • Role access: Admin only.

/app/[slug]/settings/billing

  • Purpose: Plan overview, Stripe portal access, trial status.
  • Components:
    • PlanCard — current plan name, inbox count used/limit, billing period, next invoice.
    • Trial status banner (if in trial): days remaining, progress bar, "Upgrade now" CTA.
    • "Manage billing" Button → opens Stripe Customer Portal (external redirect).
    • Plan comparison table for upgrade CTA.
  • Role access: Admin only.

/app/[slug]/settings/sla

  • Purpose: SLA policies list and business hours configuration.
  • Components:
    • SLAPolicyList — each row: name, first response target, resolution target, inbox assignment, edit/delete actions.
    • "New policy" ButtonDialog with Input for name/hours, Select for inbox (or "All inboxes"), Switch for business hours only.
    • Business hours section: timezone Combobox, per-day rows with Switch (open/closed) + TimePicker start/end (pair of Select or custom time input).
  • Role access: Admin only.

/app/[slug]/settings/snippets

  • Purpose: Manage canned response snippets.
  • Components: SnippetList — name, preview of body, edit/delete. "New snippet" → Dialog with name Input + Tiptap editor for body.
  • Role access: Agent + Admin (both can manage).

/app/[slug]/settings/webhooks

  • Purpose: Manage outbound webhook endpoints.
  • Components: WebhookList — URL, enabled status (Switch), subscribed events (badges), last delivery status, test/delete. "Add endpoint" → Dialog with URL Input, events multi-Checkbox, auto-generated secret shown once.
  • Role access: Admin only. Gated by Business plan or above.

/app/[slug]/settings/api

  • Purpose: API key management.
  • Components: ApiKeyList — name, prefix (ixk_a1b2...), last used, revoke Button. "Create API key" → Dialog with name Input → shows full key once in a copyable Input with warning "You won't see this again".
  • Role access: Admin only. Gated by public_api entitlement.

/app/[slug]/settings/sso

  • Purpose: SSO configuration (SAML/OIDC).
  • Components: Form with IdP metadata URL or XML upload, attribute mapping Inputs, "Test SSO" button, enable/disable Switch.
  • Role access: Admin only. Gated by sso entitlement.

/app/[slug]/settings/audit

  • Purpose: Audit log viewer.
  • Components: AuditLogTable — TanStack Table: timestamp, actor (name + avatar), action (human-readable), resource, metadata expandable row. Filters: date range, actor, action type. No delete/edit — read only.
  • Role access: Admin only.

Reports

/app/[slug]/reports

  • Purpose: Basic and advanced reporting depending on plan.
  • Layout: App shell. Report nav sub-tabs: Overview, By Inbox, By Agent, SLA (advanced only), Export (advanced only).
  • Components:
    • DateRangePicker — preset ranges (7d, 30d, 90d) + custom.
    • InboxFilterSelect to filter charts by inbox.
    • StatCard — single metric (e.g. "42 tickets created", "3h avg response time"). Grid of 4–6 stat cards at top.
    • BarChart (Recharts) — tickets created/resolved per day.
    • LineChart (Recharts) — average response time over period.
    • AgentTable — TanStack Table: agent name, tickets handled, avg response time, resolution rate.
    • SlaComplianceCard — % met, breach count, per-inbox breakdown (advanced only).
    • ExportButton — "Export CSV" triggers server-side generation + download (advanced only).
  • Role access: All roles can view. Export: Agent + Admin.

Profile

/app/[slug]/profile

  • Purpose: Personal settings: name, password, theme, notification preferences.
  • Components:
    • Tabs: "Account" (name, email, change password), "Notifications" (per-event toggle matrix: Switch for email and in-app per event type), "Appearance" (theme toggle: light/dark/system using next-themes).
  • Role access: All roles (own profile only).

Notifications panel

Not a full page — a Sheet or Popover accessible from the notification bell in the top bar.

  • Components: NotificationList — scrollable list of NotificationItems: icon (type-based), text description with linked entity, relative timestamp, unread indicator dot. "Mark all as read" button. "View all" link (optional full page /app/[slug]/notifications).
  • Behaviour: Unread count badge on bell. Polling every 30s or WebSocket push. Clicking a notification navigates to the relevant ticket/page and marks it read.

Locked / trial-expired state

/app/[slug]/locked

  • Purpose: Account locked after grace period expires.
  • Layout: App shell with sidebar greyed out. Center card.
  • Components: Card with lock icon, plan summary, "Subscribe now" CTA button (→ Stripe Checkout). Support contact link.
  • Behaviour: All tRPC mutations blocked server-side. Ticket list and detail readable. No reply, no sync.

Global UI patterns

These patterns apply consistently across the entire app:

PatternImplementation
Confirmation dialogsAlertDialog (shadcn) for all destructive actions. Always require explicit user action (click confirm or type name).
Toast notificationssonner for action feedback: success (green), error (red), info (blue). Max 3 visible at once. Auto-dismiss 4s, error stays until dismissed.
Loading statesSkeleton components matching the shape of the content being loaded. No full-page spinners except initial app load.
Empty statesEvery list has an empty state with an illustration placeholder, a headline ("No tickets yet"), a sub-line explaining what will appear here, and a CTA where appropriate.
Form validationInline error messages below each field (react-hook-form + zod). Submit button disabled while submitting. Error summary at top for server errors.
Optimistic updatesTicket status changes, assignee changes, and tag additions apply immediately in the UI before server confirmation. On error, state is rolled back and a toast shown.
Keyboard shortcutsCmd+K for command palette. Cmd+Enter to send reply. Escape to close dialogs. Tab navigation throughout.
Responsive breakpointssm (640px): mobile. md (768px): tablet — sidebar collapses. lg (1024px): desktop — 2-column layout. xl (1280px): wide — 3-column ticket view.
Role-based UIActions unavailable to a role are hidden (not just disabled), except where showing-but-disabled provides meaningful feedback (e.g. entitlement-gated features show with "Upgrade" badge).
Entitlement-gated featuresFeatures behind a paid entitlement show a Badge "Business plan" or "Add-on" with a link to billing. Not hidden — visible as an upgrade prompt.

7. Security & Compliance

7.1 Credential and token storage

All sensitive data is encrypted at rest:

DataStorage approach
IMAP/SMTP credentials (password)Encrypted column using AES-256 (application-level encryption key from env); never stored in plaintext
OAuth tokens (Gmail, Microsoft)Encrypted column; same approach as above; access token + refresh token stored together
API keys (public API)Stored as hashed value (e.g. SHA-256); shown only once on creation
Session tokensRandom, stored in sessions table (Lucia); HTTP-only cookies; short-lived with refresh
Stripe keysEnvironment variables only; never in DB
Postmark keyEnvironment variable only

Encryption keys are managed via environment variables (Fly.io secrets).

Key rotation (credentials and OAuth tokens): Before production, document and test a key rotation procedure. Recommended approach: (1) Generate a new ENCRYPTION_KEY; (2) Run a one-off migration or script that decrypts each credentials_encrypted (and any OAuth token blobs) with the old key and re-encrypts with the new key; (3) Deploy with the new key; (4) Retire the old key. During rotation, avoid deploying app versions that only have the new key before re-encryption is complete. A runbook should describe the exact steps and rollback. Key rotation for session signing (AUTH_SECRET) requires invalidating all existing sessions (users re-login).

7.2 Authentication and session security

  • Sessions are HTTP-only, Secure, SameSite=Lax cookies.
  • Session expiry: configurable; default 30 days with sliding expiry; absolute max 90 days.
  • Sessions are invalidated on sign-out; all sessions for a user can be invalidated (e.g. on password change).
  • Password reset tokens are single-use, time-limited (e.g. 1 hour), and invalidated after use.
  • Invite tokens are single-use and expire (e.g. 7 days).
  • Brute-force protection on sign-in: rate limit by IP and by email (see Section 4.8).

7.3 Authorization

  • All tRPC procedures check tenantId from context and verify the user belongs to that tenant.
  • Role checks are performed at the procedure level, not just in the UI.
  • PostgreSQL RLS provides a second layer of defense against tenant data leakage.
  • The public API enforces tenant scope from the API key; no cross-tenant access.
  • Webhook payloads include only the data relevant to that tenant; no global IDs that could be guessed.

7.4 Data privacy and GDPR

  • Data residency: All data is stored in the selected Neon/Fly.io region. Tenants should be informed of the region at signup.
  • Data deletion: Deleting a tenant deletes (or schedules deletion of) all associated data: inboxes, tickets, messages, attachments, users (if no other tenant memberships), and subscription records.
  • Data export: Two levels of export are available to Admins:
    1. Reports CSV export (Growth+): Raw ticket data for a selected time period, available from the Reports page (see Section 2.12). In scope for launch.
    2. Full account export (all plans): Complete export of all tenant data — all tickets, messages (body_text + body_html), attachments (as download links), team members, and inbox config — delivered as a ZIP of JSON files. Requested from Settings → General → Data Export; generated async by a worker job and emailed as a download link when ready. Post-launch; not required for v1. A manual export process serves v1 GDPR compliance.
  • User data: Users can request deletion of their account; if they have no other tenant memberships, their user record is deleted; if they do, their membership in the deleted tenant is removed.
  • Email content: Email bodies and attachments are stored for the purpose of ticket history. Tenants are responsible for their own compliance with applicable laws regarding the email content they process.
  • Cookies: Session cookie only (strictly necessary); no tracking cookies in the product.

7.5 Object storage security

  • Attachments are stored in tenant-prefixed paths (e.g. {tenant_id}/{message_id}/{filename}).
  • Download links are pre-signed URLs with short expiry (e.g. 15 minutes); no public bucket access.
  • Upload size limits enforced at the API layer before writing to storage.

7.6 Infrastructure security

  • All services communicate over HTTPS/TLS; no plaintext HTTP between services.
  • Database connections use TLS (Neon enforces this by default).
  • Secrets are stored in Fly.io secrets (env vars injected at runtime); never in source code or build artifacts.
  • Dependency scanning and security audits run in CI (e.g. pnpm audit).
  • Staging and production environments are fully isolated (separate Fly.io apps, separate Neon branches, separate secrets).

7.7 Backup and recovery

  • Database: Neon provides automatic backups and point-in-time recovery (PITR) for the project. Backup retention and RTO/RPO depend on the Neon plan; document the actual retention (e.g. 7 days PITR) in operations runbooks. Restore procedure: use Neon dashboard or API to restore from a snapshot or branch; then run migrations if needed. No application-level backup job is required for the DB at launch.
  • Attachments (S3): S3 versioning and cross-region replication are not mandated at launch; document whether enabled and what the restore process is (e.g. restore from S3 version history or from Neon backup if attachment metadata is restored). For RTO/RPO targets, define and document them in a separate operations or compliance doc.

8. Email Sync Configuration

8.1 Sync frequency per connection type

The sync frequency defines how quickly a new inbound email appears as a ticket. This affects UX, provider API quota consumption, and Inngest job volume.

Connection typeMechanismTarget latencyNotes
Gmail APIGmail Push (Pub/Sub) + fallback poll< 30 secondsPush via Google Cloud Pub/Sub sends a notification when new mail arrives; worker fetches immediately. Fallback: poll every 5 minutes if push subscription lapses.
Microsoft GraphGraph change notifications + fallback poll< 30 secondsChange notifications via Graph subscriptions; worker fetches on notification. Fallback: poll every 5 minutes.
IMAPPoll every 5 minutes< 5 minutesIMAP uses scheduled polling only — no IMAP IDLE. See rationale below.

IMAP IDLE — why it's not used:

IMAP IDLE requires a persistent TCP connection held open per inbox, waiting for server-side push. This fundamentally conflicts with the short-lived job architecture (Inngest functions). Supporting it would require a dedicated long-running daemon process separate from the worker, with its own connection pool management, reconnect logic, heartbeat, and state tracking. This adds significant infrastructure complexity for the connection type that the smallest minority of InboxOps users will use (most SMB/agency customers use Gmail or Microsoft 365).

Decision: IMAP uses 5-minute scheduled polling only. This is fully compatible with Inngest's model, runs on the same infrastructure as all other workers, and 5-minute latency is entirely acceptable for IMAP users — it's consistent with the documented fallback for all other connection types too.

Future: IDLE can be added later as a dedicated daemon process (apps/imap-daemon) on Fly.io if IMAP user volume justifies it. The polling-based pipeline does not need to change — IDLE would just trigger the same Inngest event sooner.

8.2 Fallback polling schedule

All connection types fall back to a 5-minute poll interval when push/IDLE is unavailable or as a safety net to catch missed push events. This is the floor — no inbox syncs more frequently than once per minute regardless of push activity to avoid hammering provider APIs.

8.3 Gmail Push — Pub/Sub setup and watch management

Gmail push notifications require Google Cloud Pub/Sub. This is a one-time infrastructure setup per environment (prod and staging).

One-time setup (per environment):

  1. Create a Google Cloud Pub/Sub topic (e.g. inboxops-gmail-push-prod).
  2. Grant gmail-api-push@system.gserviceaccount.com the roles/pubsub.publisher role on the topic.
  3. Create a Pub/Sub push subscription pointing to https://<app-url>/api/webhooks/gmail-push. This endpoint is handled by the worker; it verifies the Pub/Sub JWT, extracts historyId and emailAddress, and enqueues an Inngest "fetch new messages" job for the matching inbox.
  4. Store the topic name in env: GOOGLE_PUBSUB_TOPIC.

Per-inbox watch registration:

  • When a Gmail inbox is connected (or re-authenticated), the worker calls users.watch() via the Gmail API to register the inbox for push notifications.
  • watch() response contains an expiration timestamp (always ≤ 7 days from now).
  • Store expiration in inboxes.gmail_watch_expires_at (new field).
  • A daily Inngest cron job checks for inboxes where gmail_watch_expires_at < now() + 24h and calls users.watch() again to renew before expiry.

New field on Inbox: gmail_watch_expires_at | timestamp nullable | Gmail push watch expiration; renewed daily by cron

Failure mode: If watch renewal fails, the inbox falls back to 5-minute polling automatically (the fallback poll cron continues regardless of push state).

8.4 Microsoft Graph — Change notification subscription management

Graph change notifications for mail require subscribing per inbox via the POST /subscriptions API.

Per-inbox subscription:

  • When a Microsoft inbox is connected (or re-authenticated), the worker creates a change notification subscription for the inbox's mailbox: changeType: created, resource: me/mailFolders('Inbox')/messages.
  • The notification endpoint is https://<app-url>/api/webhooks/graph-push (handled by worker; validates the Graph client state token and enqueues Inngest job).
  • Graph mail subscriptions expire after 3 days (72 hours) maximum.
  • Store the subscription ID in inboxes.graph_subscription_id and expirationDateTime in inboxes.graph_subscription_expires_at (two new fields).
  • A cron job runs every 12 hours and renews (PATCH /subscriptions/{id}) any subscription expiring within 24 hours.

New fields on Inbox:

  • graph_subscription_id | string nullable | Graph change notification subscription ID
  • graph_subscription_expires_at | timestamp nullable | Graph subscription expiry; renewed by cron every 12h

Validation handshake: When Graph sends a validationToken query param to the push endpoint (subscription creation handshake), the endpoint must respond with the token as text/plain within 10 seconds. The worker handles this before any other logic.

Failure mode: If renewal fails, inbox falls back to 5-minute polling. Worker logs the error and updates inboxes.last_error.

8.5 Rate limit alignment

Google Gmail API allows ~1 billion quota units per day per project. A single messages.list + messages.get call consumes ~10 units. At 5-minute polling across many inboxes, quota should be monitored and alerted on as tenant count grows.

Microsoft Graph has per-app throttling limits; change notifications reduce polling pressure. Monitor 429 responses and implement exponential backoff per inbox.

IMAP providers vary. Aggressive polling can trigger provider-side rate limits or account lockouts. The 5-minute fallback floor is deliberately conservative.

8.6 Sync state per inbox

Each inbox stores the state needed to resume sync from where it left off:

Connection typeState field
Gmailinboxes.gmail_history_id — last processed Gmail history ID
Microsoft Graphinboxes.graph_delta_token — delta token for incremental sync
IMAPinboxes.imap_last_uid — last seen UID per mailbox folder

On reconnect after a gap, the worker uses the stored state to fetch only new messages. If the stored state is too old (e.g. Gmail history expired after 7 days of inactivity), a full re-sync is triggered — fetching recent messages from the last N days (e.g. 30) and deduplicating against existing message_id_header values.


9. Environment Variables Reference

All environment variables required to run InboxOps. Required in both apps/web and apps/worker unless noted. Managed via Fly.io secrets in production and staging; .env.local locally.

9.1 Database

VariableRequiredDescription
DATABASE_URLPostgreSQL connection string (Neon); includes pooler URL for web, direct URL for migrations
DATABASE_URL_UNPOOLEDDirect (non-pooled) Neon connection; used for migrations and Drizzle Studio

9.2 Auth

VariableRequiredDescription
AUTH_SECRETRandom secret used by Lucia for session signing; min 32 chars; generate with openssl rand -hex 32

9.3 Encryption

VariableRequiredDescription
ENCRYPTION_KEYAES-256 key for encrypting inbox credentials and OAuth tokens; base64-encoded 32-byte key

9.4 Email: Google (Gmail API)

VariableRequiredDescription
GOOGLE_CLIENT_IDOAuth2 client ID from Google Cloud Console
GOOGLE_CLIENT_SECRETOAuth2 client secret
GOOGLE_REDIRECT_URIOAuth2 callback URL (e.g. https://inboxops.app/api/auth/google/callback)
GOOGLE_PUBSUB_TOPICFull Pub/Sub topic name e.g. projects/inboxops/topics/gmail-push-prod; used when registering inbox watches

9.5 Email: Microsoft (Graph API)

VariableRequiredDescription
MICROSOFT_CLIENT_IDApp registration client ID from Azure Portal
MICROSOFT_CLIENT_SECRETApp registration client secret
MICROSOFT_REDIRECT_URIOAuth2 callback URL (e.g. https://inboxops.app/api/auth/microsoft/callback)
MICROSOFT_TENANT_IDcommon for multi-tenant apps (supports any Microsoft account)
GRAPH_PUSH_NOTIFICATION_URLPublic URL for Graph change notification deliveries e.g. https://inboxops.app/api/webhooks/graph-push

9.6 Transactional email (Postmark)

VariableRequiredDescription
POSTMARK_API_KEYServer API token from Postmark; used for invite, password reset, and system notification emails
POSTMARK_FROM_ADDRESSVerified sender address (e.g. noreply@inboxops.app)

9.7 Billing (Stripe)

VariableRequiredDescription
STRIPE_SECRET_KEYStripe secret key (sk_live_... or sk_test_...); server-only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYStripe publishable key; exposed to the browser for Stripe.js — must have NEXT_PUBLIC_ prefix
STRIPE_WEBHOOK_SECRETWebhook signing secret from Stripe dashboard; used to verify incoming webhooks
STRIPE_STARTER_PRICE_IDStripe Price ID for the Starter plan
STRIPE_GROWTH_PRICE_IDStripe Price ID for the Growth plan
STRIPE_BUSINESS_PRICE_IDStripe Price ID for the Business plan

Enterprise: No STRIPE_ENTERPRISE_PRICE_ID — Enterprise plans are created manually (Stripe Invoicing or custom subscription) and synced via the same webhooks; plan_id is set to enterprise when the subscription is linked.

9.8 Async jobs (Inngest)

VariableRequiredDescription
INNGEST_EVENT_KEYInngest event key for sending events from the app
INNGEST_SIGNING_KEYInngest signing key for verifying webhook payloads from Inngest cloud

9.9 Object storage (AWS S3)

VariableRequiredDescription
AWS_ACCESS_KEY_IDIAM user access key; policy must allow S3 read/write on the attachments bucket only
AWS_SECRET_ACCESS_KEYIAM user secret key
AWS_REGIONS3 bucket region (e.g. eu-central-1)
AWS_S3_BUCKETBucket name (e.g. inboxops-prod or inboxops-staging)

9.10 Application

VariableRequiredDescription
APP_URLPublic base URL (e.g. https://inboxops.app); used for redirect URIs, email links, and OAuth callbacks
NODE_ENVproduction, staging, or development
LOG_LEVELLogging verbosity; default info; set to debug in development

9.11 Error tracking (Sentry)

VariableRequiredDescription
SENTRY_DSNSentry DSN for apps/web and apps/worker; used by both apps (same Sentry project)
NEXT_PUBLIC_SENTRY_DSNSame DSN exposed to the browser for client-side error capture; must have NEXT_PUBLIC_ prefix
SENTRY_AUTH_TOKENAuth token for uploading source maps at build time (CI only; not needed at runtime)
SENTRY_ORGSentry organization slug (CI only)
SENTRY_PROJECTSentry project slug (CI only)

9.12 Marketing site

VariableRequiredDescription
CONTACT_EMAILRecipient address for contact form submissions (e.g. hello@inboxops.app)
NEXT_PUBLIC_PLAUSIBLE_DOMAINDomain for Plausible Analytics (e.g. inboxops.app); omit in development to disable analytics

9.13 Local development notes

  • Copy .env.example to .env.local and fill in values.
  • For Google and Microsoft OAuth, register http://localhost:3000/api/auth/google/callback and /microsoft/callback separately in the provider console.
  • Inngest provides a local dev server (npx inngest-cli dev) for testing functions without cloud connectivity.
  • Neon branching: local development should use a personal Neon branch or the Neon local proxy to avoid polluting staging.
  • Gmail Push (Pub/Sub) and Microsoft Graph change notifications require a public URL. Use a tunnel (ngrok or cloudflared) in local development, or disable push and rely on the 5-minute fallback poll (set DISABLE_PUSH_NOTIFICATIONS=true).
  • Sentry: use the development environment; errors are captured but shown in a separate "dev" stream. Source map upload is skipped locally.
  • Marketing pages (/, /pricing, etc.) are available locally at localhost:3000 alongside the app at localhost:3000/app/[slug]/.

10. Entity Lifecycle — Edit, Disconnect & Delete

Every object a tenant can create in InboxOps can also be edited and removed. This section defines the full lifecycle for each entity: what can be changed, how deletion works, and what happens to dependent data.

Guiding principles:

  • Nothing is silently destroyed: Deletions that affect dependent data are either soft-deletes, cascades with clear UI warnings, or blocked until the user resolves dependencies.
  • No orphaned data: When a parent entity is deleted, child data is either reassigned, archived, or deleted — never left in an inconsistent state.
  • Always reversible where possible: Prefer archive/disconnect over hard delete for entities that carry operational history.

10.1 Inboxes

Edit

  • Inbox name, email address display name, and auto-response settings can be edited at any time.
  • Connection type cannot be changed in-place (e.g. from IMAP to Gmail); the inbox must be disconnected and reconnected with the new type.
  • IMAP/SMTP credentials can be updated (e.g. password changed); a connection test runs on save.
  • OAuth connections (Gmail, Microsoft) can be re-authenticated (re-run OAuth flow) without losing history.

Disconnect

  • An inbox can be disconnected without deleting it. Disconnecting stops all sync and send jobs for that inbox.
  • Existing tickets and messages are fully preserved.
  • The inbox shows as "Disconnected" in the UI; agents can still view existing tickets but replies will fail until reconnected — surfaced clearly in the reply composer.
  • The inbox can be reconnected at any time via the inbox settings wizard.

Delete

  • An inbox can be permanently deleted by an Admin.
  • Before confirming, the UI shows the number of open tickets in this inbox and requires the Admin to choose:
    1. Transfer open tickets to another inbox (tenant selects the target inbox from a dropdown).
    2. Close all open tickets (status set to closed).
  • Resolved and closed tickets remain in the system associated with the deleted inbox as read-only history; inbox_id on those tickets is preserved but the inbox record is soft-deleted (inboxes.deleted_at).
  • Aliases, settings, and auto-response config are deleted with the inbox.

10.2 Tickets

Edit

  • Subject can be edited by agents.
  • Status, assignee, and tags can be changed at any time.
  • Inbox can be changed (ticket moved to a different inbox within the same tenant); the full message thread moves with it.

Delete

  • Tickets can be permanently deleted by Admins only.
  • Deletion removes the ticket and all associated messages, internal notes, and attachments (S3 objects deleted async).
  • UI requires explicit confirmation: "This will permanently delete the ticket and all N messages. This cannot be undone."
  • Hard delete — no soft delete for tickets.

Bulk actions

  • Agents can select multiple tickets from the list and: change status, reassign, add/remove tag, move to another inbox, or delete (Admin only).

10.3 Messages

  • Individual messages cannot be deleted by agents (preserves conversation integrity and audit trail).
  • Admins can delete individual messages if required (e.g. GDPR erasure request); hard delete of the message record and any associated attachments.
  • Deleting a message does not delete the ticket; if the first message is deleted, the ticket subject is preserved as-is.

10.4 Users & Team Members

Edit membership

  • An Admin can change any team member's role at any time (Admin ↔ Agent ↔ Viewer).
  • Role changes take effect immediately on the next request.

Remove from tenant

  • An Admin can remove a user from the tenant; this deletes the tenant_memberships record.
  • The user's global account is not deleted (they may belong to other tenants).
  • On removal, the Admin is prompted to optionally reassign all open tickets assigned to that user to another agent; if skipped, tickets remain assigned and show the user as "Former member".
  • The removed user loses tenant access immediately; active sessions for that tenant are invalidated.

Delete user account

  • A user can delete their own account from Profile settings.
  • If the user is the sole Admin of any tenant, they are blocked from deleting their account until they assign another Admin or delete the tenant first.
  • Deleting the account removes the users record and all tenant_memberships; assigned tickets retain the reference but show "Deleted user".

10.5 Tags

Edit

  • Tag name and color can be edited by Admins at any time; changes are reflected immediately on all tickets.

Delete

  • Admins can delete a tag; it is removed from all tickets (removes ticket_tags rows).
  • UI warning: "This tag will be removed from N tickets."

10.6 Canned Responses / Snippets

Edit

  • Name and body can be edited by any Agent or Admin; changes are available immediately in the reply composer.

Delete

  • Any Agent or Admin can delete a snippet; previously sent replies are unaffected (content was already copied into the message).

10.7 SLA Policies

Edit

  • Name, target times, inbox assignment, and business hours setting can be edited by Admins.
  • Edits apply to future SLA evaluations only; tickets already created with this policy retain their existing SLA timestamps.

Delete

  • Admins can delete an SLA policy.
  • Tickets with sla_policy_id pointing to the deleted policy have it set to null (no SLA enforcement going forward).
  • UI warning: "N open tickets use this policy. They will no longer be SLA-tracked after deletion."

10.8 Webhook Endpoints

Edit

  • URL, secret, and subscribed event types can be edited by Admins at any time; changes take effect for the next event delivery.

Delete

  • Admins can delete a webhook endpoint; pending delivery jobs for this endpoint are cancelled.

10.9 API Keys

  • API keys cannot be edited (the key value is immutable).
  • An Admin can revoke a key at any time; the key is immediately invalid for all future requests.
  • After revocation a new key can be generated. Keys are shown only once at creation; if lost, revoke and regenerate.

10.10 Tenant (Organization)

Edit

  • Org name, slug, and timezone can be edited by Admins in Settings → General.
  • Slug change takes effect immediately; the old slug no longer resolves and must be communicated to users. Admins receive a clear warning before confirming.

Delete

  • Available in Settings → General → Danger Zone; Admin must type the org name to confirm.
  • Deletion sequence:
    1. Stripe subscription is cancelled via API.
    2. All DB records are hard-deleted: inboxes, tickets, messages, users (if no other memberships), subscriptions, entitlements, audit log.
    3. S3 attachments are queued for async batch deletion.
  • This action is irreversible. No recovery after tenant deletion.

10.11 Summary table

EntityEditDisconnectDeleteWhat happens to dependent data
Inbox✅ Name, settings, credentials✅ Stops sync; history preserved✅ Admin onlyOpen tickets: transfer or close; closed tickets: preserved read-only
Ticket✅ Subject, status, assignee, inbox✅ Admin onlyMessages, attachments: hard deleted
Message✅ Admin only (GDPR)Attachments: hard deleted
Team member✅ Role✅ Remove from tenantAssigned tickets remain; optional reassign prompt
Tag✅ Name, colorRemoved from all tickets
Snippet✅ Name, bodyNone
SLA Policy✅ All fieldsAffected tickets: sla_policy_id → null
Webhook endpoint✅ URL, secret, eventsPending jobs cancelled
API key✅ RevokeImmediate invalidation
Tenant✅ Name, slug, timezone✅ Type-to-confirmAll data hard deleted; Stripe cancelled first

11. Document Status

This specification is the single source of truth for product definition, feature set, tech stack, architecture, data model, UX flows, security posture, environment configuration, and entity lifecycle. Update it when the product or technical decisions change. Implementation plans, milestones, and task breakdowns belong in separate planning documents.