InboxOps: Product & Architecture Specification
Table of Contents
- 1. Product Definition
- 2. Feature Specification
- 2.1 Tenant & Onboarding
- 2.2 Inbox Management
- 2.3 Email Ingestion & Processing
- 2.4 Ticket Management
- 2.5 User & Role Management
- 2.6 Billing & Subscriptions
- 2.7 UI & Theming
- 2.8 Integrations (Webhooks, Public API)
- 2.9 Admin & Settings
- 2.10 Notification System
- 2.10b Email Templates
- 2.11 Search
- 2.12 Reporting
- 2.13 Marketing Website
- 3. Tech Stack Decisions
- 3.1 Monorepo and package management
- 3.2 Frontend
- 3.3 API layer
- 3.4 Database and ORM
- 3.5 Auth
- 3.6 Email: inbox connection and delivery
- 3.7 System notifications (transactional email)
- 3.8 Async jobs and queue
- 3.9 Billing
- 3.10 Feature flags
- 3.11 Hosting and environment
- 3.12 CI/CD and testing
- 3.13 Internationalisation (i18n)
- 3.14 Object storage
- 3.15 Error tracking and observability
- 4. Architecture
- 4.1 Monorepo structure
- 4.2 Multi-tenancy model
- 4.3 Tenant context resolution
- 4.4 Middleware stack
- 4.5 Multi-tenant isolation in workers
- 4.6 API design
- 4.7 Email pipeline (end-to-end)
- 4.8 Async job architecture
- 4.9 Rate limits
- 4.10 Ingestion concurrency and idempotency
- 4.11 Real-time update strategy
- 4.12 Staging environment
- 5. Data Model
updated_atstrategy- 5.1 Tenant
- 5.2 User
- 5.3 TenantMembership
- 5.4 Inbox
- 5.5 Ticket
- 5.6 Message
- 5.7 Subscription / TenantPlan
- 5.8 TenantEntitlement
- 5.9 Notification
- 5.10 SlaPolicy
- 5.11 AuditLog
- 5.12 Session
- 5.13 Tag
- 5.14 Invitation
- 5.15 Snippet (Canned Response)
- 5.16 WebhookEndpoint
- 5.17 WebhookDelivery
- 5.18 ApiKey
- 5.19 FeatureFlag
- 5.20 NotificationPreference
- 5.21 BusinessHours
- 5.21b Other entities (Attachment, ContactSubmission, InboxAlias, FeatureFlagOverride)
- 5.22 Database Indexes
- 6. UX Flows
- 6.6 Reply composer — Rich Text (WYSIWYG)
- 6.7 Attachment limits
- 6.8 Error and system state pages
- 6.9 Command palette
- 6.10 Frontend page inventory and component structure
- Component strategy
- Layout shells
- Public pages
- No workspace
- Onboarding
- Core app — Tickets
- Core app — Inboxes
- Settings
/app/[slug]/settings/general/app/[slug]/settings/inboxes/app/[slug]/settings/inboxes/new/app/[slug]/settings/inboxes/[id]/app/[slug]/settings/team/app/[slug]/settings/billing/app/[slug]/settings/sla/app/[slug]/settings/snippets/app/[slug]/settings/webhooks/app/[slug]/settings/api/app/[slug]/settings/sso/app/[slug]/settings/audit
- Reports
- Profile
- Notifications panel
- Locked / trial-expired state
- Global UI patterns
- 7. Security & Compliance
- 8. Email Sync Configuration
- 9. Environment Variables Reference
- 9.1 Database
- 9.2 Auth
- 9.3 Encryption
- 9.4 Email: Google (Gmail API)
- 9.5 Email: Microsoft (Graph API)
- 9.6 Transactional email (Postmark)
- 9.7 Billing (Stripe)
- 9.8 Async jobs (Inngest)
- 9.9 Object storage (AWS S3)
- 9.10 Application
- 9.11 Error tracking (Sentry)
- 9.12 Marketing site
- 9.13 Local development notes
- 10. Entity Lifecycle — Edit, Disconnect & Delete
- 11. Document Status
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 type | Examples | Why InboxOps is different |
|---|---|---|
| Heavy ITSM / helpdesk | Zendesk, Freshdesk | Too complex, per-agent pricing is expensive for small teams, steep onboarding |
| Shared inbox tools | Missive, Front | Collaboration-first, not ticketing-first; limited workflow |
| SMB-focused ticketing | Help Scout, Groove | Good products but per-user pricing; limited multi-tenancy for agencies |
| Open source self-hosted | Zammad, osTicket | Require 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
| Principle | Meaning |
|---|---|
| Guided onboarding | New tenants go through a step-by-step setup (org, first inbox, first user). No blank dashboard. |
| Setup wizards | Complex or one-time tasks (e.g. connecting an inbox, configuring billing) use wizards with clear steps and validation. |
| Clean, minimal UI | Only essential controls and information. No steep learning curve. |
| Dark and light mode | Full theme support; user preference is persisted and applied globally. |
| Consistent patterns | Same 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
| Plan | Inboxes included | Add-ons included | Notes |
|---|---|---|---|
| Starter | Up to 2 inboxes | — | For individuals and very small teams |
| Growth | Up to 10 inboxes | Advanced reporting | For growing teams and small agencies |
| Business | Up to 30 inboxes | Advanced reporting, Public API, Webhooks | For larger teams and agencies |
| Enterprise | Unlimited (custom) | All add-ons incl. SSO | Custom 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). Whiletrial_ends_atis 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_endcan 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 tostarter,growth,business, orenterpriseon 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:
tenantstable; 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:
inboxestable: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_erroris updated with a clear error code (e.g.oauth_refresh_failed) andconnected_atis 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.
- If token refresh fails, the inbox
- 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'andinboxes.connected_at = null, enqueues a "notify admins" job, and returns without retrying. Sync jobs for this inbox are skipped (checkconnected_at IS NULLbefore 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_ator 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_aliasesorinbox.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:
ticketstable;messagestable withdirection: '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 ticketupdated_atand 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;
attachmentstable 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-Statusheader or Gmail/Graph spam labels; skip message (no ticket created). - Bounces: detect
Content-Type: message/delivery-statusorAuto-Submitted: auto-repliedheaders; link to the originating outbound message by settingmessages.bounce_received_aton 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_replyheaders before sending auto-responses. - Deduplication:
message_id_headermust be unique per inbox or globally; upsert-safe ingestion.
- Spam: check
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.statusenum; status change mutation; optionalstatus_updated_atfor 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_idFK 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:
tagstable (tenant_id, name, color);ticket_tagsjunction; ortickets.tag_idsarray; 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:
messageswithtype: 'internal_note'or separateinternal_notestable; 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:
snippetstable (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 duringstatus = '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_hourstable (see Section 5.21); SLA elapsed time function takescreated_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-Suppressheaders — 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, directionoutbound, clearly labelled "Auto-response").
- Supported template variables:
| Variable | Resolved 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 withsend_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_verifiedfield is stored in the DB and set totrueon 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;
userstable;sessionstable; password hashed with bcrypt (cost factor 12); email for reset via Postmarkpassword-resettemplate.
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:
invitationstable (email, tenant_id, role, token, expires_at); Postmark for invite email; accept endpoint validates token, creates user withpassword_hash: nullif 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_limitchecked server-side before inbox create;inbox_countderived 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, selectedprice_id,success_url/cancel_url) → returns URL → client redirects to Stripe Hosted Checkout. - On return: redirect to
success_urlshows a confirmation message only. Subscription is confirmed and state is updated via webhook, not the redirect — never trust the redirect alone. stripe_customer_idstored insubscriptionsrow after first successful checkout.
2. Managing subscription (Customer Portal):
- tRPC
billing.createPortalSession→ Stripe Billing Portal Session forstripe_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-Signatureheader againstSTRIPE_WEBHOOK_SECRETusingstripe.webhooks.constructEvent. - All handlers are idempotent — safe to replay.
| Stripe event | Action in InboxOps |
|---|---|
checkout.session.completed | Create/update subscriptions: set plan_id, status: active, stripe_subscription_id, stripe_customer_id, inbox_limit; sync entitlements |
customer.subscription.updated | Update plan_id, status, inbox_limit, current_period_end; re-sync entitlements |
customer.subscription.deleted | Set status: cancelled; clear entitlements; if within grace period keep read access, else lock |
invoice.payment_succeeded | Update current_period_end; clear past_due if set |
invoice.payment_failed | Set status: past_due; notify tenant admins via Postmark |
invoice.payment_action_required | Same as payment_failed; include Stripe-hosted payment link in notification |
Entitlement sync (run after every subscription change):
| Plan | Entitlements granted |
|---|---|
trial | advanced_reports |
starter | (none) |
growth | advanced_reports |
business | advanced_reports, public_api, webhooks |
enterprise | advanced_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_entitlementsrows. 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_membershipsfor current user; on select, redirect to/app/[slug]/tickets; optionally storelast_tenant_idor 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_endpointstable (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": { ... }
}
| Event | data fields |
|---|---|
ticket.created | ticket_id, ticket_number, subject, status, priority, inbox_id, assignee_id (nullable), created_at |
ticket.updated | ticket_id, ticket_number, changed_fields (array of field names), full ticket object |
ticket.assigned | ticket_id, ticket_number, assignee_id, assigned_by_user_id |
ticket.status_changed | ticket_id, ticket_number, old_status, new_status, changed_by_user_id |
ticket.closed | ticket_id, ticket_number, resolved_at |
message.received | message_id, ticket_id, ticket_number, direction: 'inbound', from_address, body_text (truncated to 500 chars), has_attachments |
message.sent | message_id, ticket_id, ticket_number, direction: 'outbound', sent_at |
inbox.error | inbox_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: prefixixk_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
| Method | Path | Description |
|---|---|---|
GET | /tickets | List tickets. Query params: status, inbox_id, assignee_id, priority, page, per_page (max 100). |
GET | /tickets/:id | Get single ticket by UUID or ticket_number. |
POST | /tickets | Create ticket manually. Body: subject, inbox_id, priority (optional), assignee_id (optional). |
PATCH | /tickets/:id | Update ticket. Allowed fields: subject, status, priority, assignee_id, inbox_id. |
GET | /tickets/:id/messages | List messages in ticket thread (excludes internal notes). |
POST | /tickets/:id/messages | Add internal note. Body: body_html or body_text, type: 'internal_note'. |
GET | /tickets/:id/tags | List tags on ticket. |
POST | /tickets/:id/tags | Add tag by ID or name. Body: tag_id or tag_name. |
DELETE | /tickets/:id/tags/:tag_id | Remove tag from ticket. |
Inboxes
| Method | Path | Description |
|---|---|---|
GET | /inboxes | List inboxes (active only). |
GET | /inboxes/:id | Get single inbox. |
Tags
| Method | Path | Description |
|---|---|---|
GET | /tags | List 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:
tenantstable; 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_logstable; written from tRPC mutations and worker jobs; index by tenant and time.
Events that write to audit_logs:
| Action key | Trigger | Resource |
|---|---|---|
tenant.updated | Org name, slug, or timezone changed | tenant |
inbox.connected | Inbox successfully connected | inbox |
inbox.disconnected | Inbox disconnected | inbox |
inbox.reconnected | Inbox re-authenticated | inbox |
inbox.updated | Inbox name, display name, or auto-response changed | inbox |
inbox.deleted | Inbox deleted | inbox |
ticket.created | Ticket 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.deleted | Ticket deleted by Admin | ticket |
ticket.status_changed | Status changed by agent or Admin | ticket |
ticket.assigned | Ticket assigned or reassigned | ticket |
ticket.moved | Ticket moved to different inbox | ticket |
message.deleted | Message deleted by Admin (GDPR) | message |
user.invited | Invitation sent | invitation |
user.invite_revoked | Invitation revoked | invitation |
user.role_changed | Member role changed | tenant_membership |
user.removed | Member removed from tenant | tenant_membership |
sla_policy.created | SLA policy created | sla_policy |
sla_policy.updated | SLA policy updated | sla_policy |
sla_policy.deleted | SLA policy deleted | sla_policy |
api_key.created | API key created | api_key |
api_key.revoked | API key revoked | api_key |
webhook.created | Webhook endpoint added | webhook_endpoint |
webhook.updated | Webhook endpoint edited | webhook_endpoint |
webhook.deleted | Webhook endpoint deleted | webhook_endpoint |
subscription.changed | Plan changed (via Stripe webhook) | subscription |
tenant.deleted | Tenant deletion initiated | tenant |
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:
| Plan | Retention |
|---|---|
| Starter | 30 days |
| Growth | 90 days |
| Business | 1 year |
| Enterprise | Custom (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_flagstable); 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:
- System notifications — transactional emails sent to agents/admins (via Postmark), e.g. invite, password reset, ticket assignment.
- 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
| Event | Channel | Recipient |
|---|---|---|
| User invited to tenant | Email (Postmark) | Invitee |
| Password reset requested | Email (Postmark) | User |
| Email verification | Email (Postmark) | User |
| Ticket assigned to agent | Email + In-app | Assigned agent |
| Ticket reassigned | Email + In-app | New assignee (and optionally previous) |
| New reply received on assigned ticket | Email + In-app | Assigned agent |
| Internal note mentioning agent (@mention) | Email + In-app | Mentioned agent |
| SLA breach / near-breach | Email + In-app | Assigned agent, Admins |
| Inbox connection error | Email + In-app | Tenant admins |
| Subscription / billing issue | Email (Postmark) | Tenant admins |
| Trial ending in 5 days | Email (Postmark) | Tenant admins |
| Trial ended — no subscription | Email (Postmark) + In-app banner | Tenant 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
notificationstable 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
sendEmailAPI. - 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 name | Trigger | Recipients | Key variables |
|---|---|---|---|
invite | User invited to tenant | Invitee | org_name, inviter_name, role, accept_url, expires_at |
password-reset | Password reset requested | User | reset_url, expires_in |
email-verification | Signup (if verification enabled) | User | verify_url |
ticket-assigned | Ticket assigned to agent | Assigned agent | ticket_number, ticket_subject, assigned_by, ticket_url |
ticket-reply | New inbound reply on assigned ticket | Assigned agent | ticket_number, ticket_subject, from_address, body_preview, ticket_url |
mention | @mention in internal note | Mentioned agent | ticket_number, ticket_subject, mentioned_by, note_preview, ticket_url |
sla-breach | SLA breached | Assigned agent + Admins | ticket_number, ticket_subject, sla_type (first_response/resolution), breached_at, ticket_url |
inbox-error | Inbox connection error / OAuth failure | Tenant Admins | inbox_name, error_message, reconnect_url |
trial-ending | Day 25 of trial | Tenant Admins | org_name, days_remaining, upgrade_url |
trial-expired | Day 30, no subscription | Tenant Admins | org_name, grace_period_ends, upgrade_url |
trial-locked | Day 37, account will be locked | Tenant Admins | org_name, upgrade_url |
payment-failed | Stripe invoice.payment_failed | Tenant Admins | org_name, amount_due, payment_url |
subscription-cancelled | Stripe customer.subscription.deleted | Tenant Admins | org_name, cancelled_at, resubscribe_url |
Rendering pipeline:
- Inngest job receives event data
- Imports the relevant React Email component
- Calls
render(component)→ HTML string + plain text - 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.
2.11 Search
Full-text ticket and message search
- 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/tsqueryontickets.subjectandmessages.body_text; index withGIN; tenant-scoped withtenant_idpredicate. Sender and tag are applied asWHEREfilters (e.g.from_addressilike 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:
| Aspect | Approach |
|---|---|
| Indexing | tsvector column on tickets and messages; updated via PostgreSQL generated column (stored) |
tickets.tsv definition | GENERATED ALWAYS AS (to_tsvector('english', coalesce(subject, ''))) STORED |
messages.tsv definition | GENERATED ALWAYS AS (to_tsvector('english', coalesce(body_text, ''))) STORED |
| Update strategy | Generated 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. |
| Query | tsquery with websearch_to_tsquery('english', $1) — handles quoted phrases, OR, and negation in user input |
| Ranking | ts_rank(tsv, query) for relevance ordering |
| Tenant isolation | All queries include WHERE tenant_id = $1 before FTS predicates |
| Limitations | English stemming only; no typo tolerance. Acceptable for the initial product. |
| Future | Can 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
ticketsandmessagestables 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:
| Metric | Description |
|---|---|
| Tickets created | Count of new tickets per day/week |
| Tickets resolved | Count of resolved/closed tickets per period |
| Open ticket count | Current snapshot by status |
| First response time | Avg time from ticket created to first outbound reply (first_response_at - created_at) |
| Resolution time | Avg time from ticket created to resolved (resolved_at - created_at) |
| Tickets by inbox | Volume breakdown per inbox |
| Tickets by assignee | Volume and resolution rate per agent |
Advanced reporting (Growth plan and above)
- Description: Tenants with the
advanced_reportsentitlement 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_policiesand comparingfirst_response_at/resolved_atagainst policy targets; export generates CSV server-side (streamed or pre-generated for large sets).
Additional metrics in advanced reporting:
| Metric | Description |
|---|---|
| SLA compliance rate | % of tickets that met first response and resolution SLA targets |
| SLA breach count | Count of breaches per inbox or period |
| Tag distribution | Ticket counts per tag |
| Agent workload | Open + resolved tickets per agent at a given time |
| CSV export | Raw 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
| Page | Route | Purpose |
|---|---|---|
| Landing page | / | Hero, value proposition, key features, social proof, CTA to signup |
| Pricing | /pricing | Plan comparison table, FAQ, CTA per plan, trial callout |
| Contact | /contact | Contact form (name, email, message, company); submissions stored in DB and/or forwarded via Postmark |
| Terms of Service | /legal/terms | AGB — legally required for SaaS |
| Privacy Policy | /legal/privacy | Datenschutzerklärung (GDPR-compliant) |
| Imprint | /legal/impressum | Impressum — legally required in DE/AT/CH |
| 404 | not-found | Custom 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)
| Section | Content |
|---|---|
| Hero | Headline, sub-headline, primary CTA ("Start free trial"), secondary CTA ("See pricing"); optional product screenshot or animation |
| Problem / Solution | Short narrative: shared inboxes are chaos → InboxOps brings structure |
| Key features | 3–4 feature highlights: direct mailbox connection, per-inbox pricing, tickets + threads, team assignment |
| How it works | 3-step visual: connect inbox → emails become tickets → team replies together |
| Pricing teaser | Condensed plan overview with link to /pricing |
| Social proof | Placeholder for testimonials / logos (can launch with placeholder copy) |
| Final CTA | Repeat signup CTA with trial framing: "30 days free, no credit card required" |
| Footer | Nav 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_submissionsDB table (id, name, email, company, subject, message, created_at); sends notification email to a configuredCONTACT_EMAILaddress via Postmark. - Anti-spam: honeypot field + rate limit (max 3 submissions per IP per hour).
- Success state: inline confirmation message; no page redirect.
Legal pages (Impressum, Datenschutz, AGB)
- Content is managed as MDX files in
apps/web/content/legal/— editable without code changes. - Rendered with a shared
LegalLayoutwrapper (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.mdxor a single locale); if the marketing site usesnext-intl, configure a default locale or route so that/legal/impressumserves German content for the primary market. Document the chosen approach (single language vs. locale-based legal pages).
Marketing site technical decisions
| Aspect | Decision |
|---|---|
| Rendering | Static (SSG via Next.js); all marketing pages are export const dynamic = 'force-static' |
| i18n | Uses same next-intl setup; all marketing copy in messages/en/marketing.json |
| Analytics | Plausible Analytics (privacy-friendly, no cookie banner needed for GDPR); script injected in marketing layout only — not in the app shell |
| Cookie banner | Not needed for marketing site (Plausible is cookieless); session cookie in app is strictly necessary and exempt |
| SEO | generateMetadata per page; sitemap at /sitemap.xml (auto-generated); robots.txt allows all except /app/** |
| OpenGraph images | Static OG image for landing/pricing; route-specific meta descriptions |
| CMS | No external CMS at launch; content lives in MDX files and TypeScript config. Can migrate to a headless CMS later. |
New DB table: ContactSubmission
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| name | string | |
| string | ||
| company | string nullable | |
| subject | string | Dropdown value |
| message | text | |
| ip_address | string nullable | For rate limiting; hashed or truncated for GDPR |
| created_at | timestamp |
New env vars for marketing site
| Variable | Required | Description |
|---|---|---|
CONTACT_EMAIL | ✅ | Email address that receives contact form submissions (e.g. hello@inboxops.app) |
NEXT_PUBLIC_PLAUSIBLE_DOMAIN | — | Domain 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
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| Monorepo tooling | Turborepo | Nx, Lerna, pnpm workspaces only | Turborepo gives fast, cached builds and clear task pipelines with minimal config. Fits TypeScript/Node monorepos well. |
| Package manager | pnpm | npm, Yarn | pnpm's strict dependency resolution and disk efficiency suit monorepos; workspace protocol works cleanly with Turborepo. |
3.2 Frontend
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| Framework | Next.js (App Router) | Remix, Vite + React Router | Next.js provides SSR, routing, API routes, and a single deployment unit. App Router gives RSC and clear data-fetching patterns. |
| Styling | Tailwind CSS | CSS Modules, styled-components | Tailwind enables fast, consistent UI work and pairs well with shadcn/ui; utility-first keeps custom CSS minimal. |
| Component library | shadcn/ui | MUI, Chakra, Radix only | shadcn/ui is copy-paste components (Radix + Tailwind), full control over code, accessible, and themable for dark/light. |
| Rich text editor | Tiptap | Quill, Slate.js, TinyMCE | Tiptap is headless, ProseMirror-based, React-compatible, and extensible. Outputs HTML. Supports mentions, custom extensions. No legacy baggage like Quill. |
| Data tables | TanStack Table | shadcn Table only, AG Grid | TanStack Table is headless and composable; renders with shadcn/Tailwind. Handles sorting, filtering, pagination for ticket lists, audit logs, etc. |
| Charts | Recharts | Chart.js, Victory | Recharts is React-native, composable, and works well with Tailwind theming. Sufficient for bar and line charts in reporting. |
| Form handling | react-hook-form + Zod | Formik, native forms | react-hook-form is performant and uncontrolled; Zod provides shared validation with tRPC input schemas. |
| Date handling | date-fns | dayjs, Luxon | date-fns is tree-shakeable and TypeScript-native; no global state. Used for formatting, relative time, and SLA calculations. |
| Toast notifications | sonner | react-hot-toast, shadcn Toast | sonner is minimal, accessible, and pairs well with shadcn/ui. |
| Theme management | next-themes | Manual CSS class toggle | next-themes handles SSR-safe theme switching and system preference detection; prevents FOUC. |
| Internationalisation | next-intl | react-i18next, lingui | next-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
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| Internal app API | tRPC | REST, GraphQL | tRPC gives end-to-end type safety with minimal boilerplate; no codegen or separate API spec for the main app. |
| Webhooks and public API | REST | tRPC, GraphQL | REST is standard for webhooks and third-party integrations; tooling and customer expectations are well understood. |
3.4 Database and ORM
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| Database | PostgreSQL via Neon | Supabase, PlanetScale, self-hosted Postgres | Neon is serverless Postgres with branching (good for staging), connection pooling, and no server management. |
| ORM / query layer | Drizzle ORM | Prisma, Kysely, raw SQL | Drizzle is TypeScript-first, lightweight, and works well with RLS and raw SQL when needed; migrations are explicit SQL. |
3.5 Auth
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| Auth library | Lucia | Clerk, Better Auth, NextAuth | Lucia 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
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| Inbound (fetch) | Direct mailbox — IMAP (IDLE/poll), Gmail API, Microsoft Graph | Inbound 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 mailbox | Dedicated 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 storage | Encrypted per inbox | External 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 client | imapflow | node-imap, imap | imapflow is actively maintained, Promise/async-based, supports IMAP IDLE natively, and has a clean API. node-imap is callback-based and largely unmaintained. |
| MIME parsing | mailparser (from Nodemailer) | postal-mime, emailjs-mime-parser | mailparser is battle-tested, handles encoding quirks and multipart MIME well, and is the de-facto standard in the Node email ecosystem. |
| Email construction (outbound) | nodemailer | mailcomposer standalone | nodemailer 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)
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| Transactional delivery | Postmark | SendGrid, Resend, AWS SES | Postmark is focused on transactional delivery with strong deliverability. Used only for system emails, not customer-facing replies. |
| Email template rendering | react-email | Postmark templates, MJML, hand-written HTML | react-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
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| Job queue | Inngest | BullMQ + Redis, Trigger.dev | Inngest 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
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| Payments and subscriptions | Stripe | Paddle, Lemon Squeezy | Stripe 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
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| Feature flags | DB-backed | LaunchDarkly, Unleashed | Simple 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
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| Application hosting | Fly.io | Vercel, Railway, AWS | Fly.io gives full control over regions, persistent processes for workers, and fits both HTTP app and background workers in one place. |
| Database hosting | Neon | — | Already chosen; serverless Postgres with branching. |
| Staging | Fly.io staging app + Neon branch | Separate staging DB only | Staging 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
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| CI/CD | GitHub Actions | CircleCI, GitLab CI | GitHub Actions is integrated with the repo and sufficient for build, test, and deploy; no extra vendor for basic pipelines. |
| Unit and integration tests | Vitest | Jest | Vitest is fast and ESM-friendly; good for Node and TypeScript. |
| End-to-end tests | Playwright | Cypress | Playwright supports multiple browsers and is well suited for testing full flows (auth, tickets, replies) in a real browser. |
3.15 Error tracking and observability
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| Error tracking | Sentry | Datadog, Bugsnag, Rollbar | First-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 aggregation | Logtail (Better Stack) | Datadog Logs, Papertrail | Fly.io forwards stdout logs to Logtail with minimal config; structured log support; pairs with uptime monitoring below. |
| Uptime monitoring | Better Stack Uptime | Pingdom, UptimeRobot | Same platform as Logtail; monitors /api/health endpoint; alerts on downtime via email/Slack. |
Sentry integration points:
| Location | What is captured |
|---|---|
apps/web — Server Components, Route Handlers | Unhandled server-side exceptions; performance traces |
apps/web — Client-side React | Unhandled JS errors; session replay on error for reproducing UI bugs |
apps/worker — Inngest functions | Uncaught errors in all function handlers; email sync failures; send failures; webhook delivery errors |
packages/email | IMAP/SMTP/Gmail/Graph client errors tagged with inbox_id, tenant_id |
packages/billing | Stripe webhook handler errors; failed entitlement syncs |
Sentry configuration rules:
tenant_idandinbox_idare 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 thebeforeSendhook to strip sensitive fields before transmission. - Source maps uploaded to Sentry at build time via
@sentry/nextjswebpack plugin. Source maps are not shipped in the production JS bundle. - Sentry
environmentset fromNODE_ENV:production,staging,development. - Sample rates in production: 100% for errors, 10% for performance traces (adjustable per environment).
- A
/api/healthendpoint 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
| Decision | Choice | Reason |
|---|---|---|
| i18n library | next-intl | First-class App Router support; works with RSC and Server Components; ICU message format; type-safe message keys via codegen; standard JSON format |
| Message format | ICU | Handles plurals, gender, and interpolation correctly; supported by Weblate, Phrase, Crowdin, and other TMS tools |
| Message files | JSON per locale per namespace | e.g. messages/en/tickets.json, messages/en/settings.json; namespacing keeps files manageable |
| Translation management | Weblate-compatible | JSON + 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'suseTranslationshook (client) orgetTranslationsfunction (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'suseFormatterfor locale-aware formatting (e.g.formatRelativeTime,formatDateTime). - Email templates (
react-email, Section 2.10b) are not run throughnext-intl— they are English-only strings for now. Localised emails are a post-launch concern.
Adding a new locale later:
- Create
messages/<locale>/and copy all JSON files fromen/. - Translate values (via Weblate or manual).
- Add locale to
next-intlconfig. - Expose locale switcher in Profile → Appearance. No code changes to components are required.
3.14 Object storage
| Decision | Choice | Alternatives considered | Reason |
|---|---|---|---|
| File / attachment storage | AWS S3 | Cloudflare R2, Tigris (Fly.io native), Supabase Storage | S3 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_IDandAWS_SECRET_ACCESS_KEYstored 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
webandworker.
4.2 Multi-tenancy model
- Row-level tenancy: Every tenant-scoped table has a
tenant_idcolumn. All queries that read or write tenant data includetenant_idin the predicate. - PostgreSQL RLS: Row-Level Security policies are defined on tenant-scoped tables so that, when the session variable
app.current_tenant_idis 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:
| Concern | Path routing | Subdomain routing |
|---|---|---|
| DNS setup | Single A record | Wildcard DNS (*.inboxops.app) required |
| SSL/TLS | Single cert | Wildcard cert (or per-tenant cert via Let's Encrypt) |
| OAuth redirect URIs | One URI per provider | One URI per provider (slug not in redirect path) |
| Cookie scope | Standard; no cross-domain complexity | Must set Domain: .inboxops.app — shares cookies across tenants |
| Next.js middleware | Read slug from pathname | Read slug from Host header |
| Local development | Works with localhost out of the box | Requires /etc/hosts entries or tunnel |
| Complexity | Simpler | More 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:
- Next.js middleware matches
/app/[slug]/**. - Extract
slugfrompathname(e.g.acmefrom/app/acme/tickets). - Look up tenant by
slugin DB → if not found, show 404/tenant-not-found page. - Validate session (Lucia) → if no session, redirect to
/login?redirect=<current-url>. - Verify
tenant_membershipsrow exists for(tenantId, userId)→ if not, redirect to/login. - 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:
- Extract
slugfrom pathname → look uptenantsby slug → if not found, return 404 - Read session cookie → validate with Lucia → if invalid, redirect to
/login?redirect=<url> - Verify
tenant_membershipsrow for(tenantId, userId)→ if not, redirect to/login - Check
onboarding_completed_at→ if null and not on/app/[slug]/onboarding, redirect there - Check subscription status → if
locked, redirect to/app/[slug]/locked(except/lockeditself) - Set
tenantId,userId,rolein request headers for Server Components and tRPC context
/api/v1/** middleware sequence:
- Extract
Authorization: Bearer <key>→ hash → look upapi_keysbykey_hash - If not found or
revoked_atis set →401 Unauthorized - Load tenant from
api_keys.tenant_id - Check
tenant_entitlementsforpublic_api→ if not entitled →403 Forbidden - Check rate limit (DB sliding window) → if exceeded →
429 Too Many RequestswithRetry-After - Attach
tenantIdto 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_idin 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 receivestenantIdfrom 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)
-
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, thenmessageswith 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.
-
Outbound
- User submits reply in UI → tRPC mutation.
- Server validates (ticket exists, user can reply), inserts
messagesrow (outbound,send_status: 'pending'), enqueues Inngest "send email" job. There is no separate "draft" state — unsent replies are stored aspendinguntil 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 inwebif 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; callsusers.watch()for each and updatesgmail_watch_expires_at. - Graph subscription renewal: Cron function runs every 12 hours; queries inboxes where
graph_subscription_expires_at < now() + 24h; callsPATCH /subscriptions/{id}for each and updatesgraph_subscription_expires_at. - Webhook delivery pruning: Cron function runs weekly; deletes
webhook_deliveriesrows older than 30 days or beyond the 100-per-endpoint cap. - Audit log pruning: Cron function runs weekly; deletes
audit_logsrows 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.
| Scope | Limit | Enforcement |
|---|---|---|
| Email fetch per inbox | Max N syncs per hour (e.g. 60) | Inngest concurrency + rate limit |
| Email send per tenant | Max N outbound messages per minute | Inngest rate limit |
| Public API per tenant | e.g. 100 req/min | DB-based counter (api_rate_limits table or Neon; increment + TTL via timestamp window) |
| Webhook delivery | Exponential 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 user | Middleware |
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_headerhas 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_headeralready exists for that inbox, the insert is skipped (upsert withON 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, insertmessage, optionally insertnotificationrows. If any step fails, the whole transaction rolls back. - The job is retried by Inngest. Because
message_id_headeris 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
messagesfor a knownmessage_id_headerbefore 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:
| Option | Why rejected |
|---|---|
| WebSockets | Fly.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 Events | Same machine-pinning problem as WebSockets. Each open SSE connection counts against the machine's connection limit. |
| Long polling | Open 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 polling | Stateless 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:
| Context | Interval | Trigger |
|---|---|---|
| Ticket thread (ticket detail open) | 5 seconds | refetchInterval: 5000 on message list query |
| Ticket list | 15 seconds | refetchInterval: 15000; also refetches on window focus |
| Notification bell count | 30 seconds | refetchInterval: 30000; also refetches on window focus |
| Inbox sync status (settings page) | 10 seconds | Only 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 frommainor reset. Migrations run against this branch. - Fly.io: One staging app (e.g.
inboxops-staging) with its own config;DATABASE_URLpoints 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
stagingbranch; production deploys frommainor 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
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| name | string | Display name |
| slug | string | Unique, URL-safe; used for path-based tenant resolution (e.g. /app/acme/tickets) |
| onboarding_completed_at | timestamp nullable | Null = show onboarding wizard |
| trial_ends_at | timestamp nullable | Set to now() + 30 days on signup; null after subscription starts |
| ticket_number_seq | int | Auto-incrementing counter for human ticket IDs; incremented on each ticket insert |
| created_at | timestamp | |
| updated_at | timestamp |
5.2 User
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| name | string | Full name; shown in assignee dropdowns, audit log, @mentions, notifications |
| string | Unique, lowercase | |
| email_verified | boolean | |
| password_hash | string nullable | Null if SSO-only |
| avatar_url | string nullable | Optional profile image URL; if null, UI renders initials avatar from name |
| created_at | timestamp | |
| updated_at | timestamp |
Users are global; tenant association is through tenant_memberships.
5.3 TenantMembership
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| user_id | uuid | FK → users |
| role | enum | 'admin' | 'agent' | 'viewer' |
| created_at | timestamp | |
| updated_at | timestamp |
Unique (tenant_id, user_id).
5.4 Inbox
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| name | string | Internal display name (shown in UI only) |
| email_address | string | Primary sending/receiving address |
| display_name | string nullable | Name shown in email From header e.g. "Acme Support"; if null, email_address is used alone |
| connection_type | enum | 'imap_smtp' | 'gmail' | 'microsoft' |
| credentials_encrypted | blob/text | Encrypted credentials or token set |
| last_error | string nullable | Last sync/send error |
| connected_at | timestamp nullable | |
| archived_at | timestamp nullable | |
| deleted_at | timestamp nullable | Soft delete; excluded from all active queries |
| gmail_history_id | string nullable | Last processed Gmail history ID (Gmail only) |
| gmail_watch_expires_at | timestamp nullable | Gmail Pub/Sub watch expiry; renewed daily by cron (Gmail only) |
| graph_delta_token | string nullable | Delta token for incremental Graph sync (Microsoft only) |
| graph_subscription_id | string nullable | Graph change notification subscription ID (Microsoft only) |
| graph_subscription_expires_at | timestamp nullable | Graph subscription expiry; renewed every 12h by cron (Microsoft only) |
| imap_last_uid | bigint nullable | Last seen IMAP UID (IMAP only) |
| auto_response_enabled | boolean | Default false; sends auto-reply on new ticket creation |
| auto_response_body | text nullable | Template body; supports {{ticket_number}}, {{org_name}}, {{inbox_name}}, {{sender_name}} |
| created_at | timestamp | |
| updated_at | timestamp |
Outbound From header construction:
- If
display_nameis set:From: Acme Support <support@acme.com> - If
display_nameis 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
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| inbox_id | uuid | FK → inboxes |
| ticket_number | int | Human-readable ID, sequential per tenant (e.g. 1042); set from tenants.ticket_number_seq on insert |
| subject | string | From first message |
| status | enum | open, in_progress, waiting, resolved, closed |
| priority | enum | 'low' | 'normal' | 'high' | 'urgent'; default 'normal' |
| assignee_id | uuid nullable | FK → users |
| first_response_at | timestamp nullable | When first outbound reply was sent |
| resolved_at | timestamp nullable | When status set to resolved |
| sla_policy_id | uuid nullable | FK → sla_policies |
| tsv | tsvector | Generated: GENERATED ALWAYS AS (to_tsvector('english', coalesce(subject, ''))) STORED; defined via raw SQL in migration (Drizzle does not support generated columns natively) |
| created_at | timestamp | |
| updated_at | timestamp |
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 #{{ticket_number}} - 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
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| inbox_id | uuid | FK → inboxes; denormalized from ticket for deduplication constraint |
| ticket_id | uuid | FK → tickets |
| direction | enum | 'inbound' | 'outbound' |
| type | enum | 'email' | 'internal_note' |
| send_status | enum nullable | null for inbound; 'pending' | 'sending' | 'sent' | 'failed' for outbound |
| send_error | string nullable | Error message if send_status = 'failed' |
| bounce_received_at | timestamp nullable | Outbound only: set when a bounce notification is linked to this message (see Section 2.3); UI shows "Delivery failed" / "Bounce received" |
| message_id_header | string nullable | RFC Message-ID for threading + dedup |
| in_reply_to | string nullable | |
| from_address | string | |
| to_addresses | string / json | |
| body_html | text nullable | |
| body_text | text nullable | |
| tsv | tsvector | Generated: GENERATED ALWAYS AS (to_tsvector('english', coalesce(body_text, ''))) STORED; defined via raw SQL in migration |
| sent_at | timestamp nullable | For outbound, when actually sent by worker |
| created_at | timestamp |
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
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants, unique |
| stripe_customer_id | string nullable | |
| stripe_subscription_id | string nullable | Null during trial |
| plan_id | string | 'trial', 'starter', 'growth', 'business', 'enterprise' |
| inbox_limit | int | From plan; 10 during trial (Growth equivalent) |
| status | enum | 'trialing' | 'active' | 'past_due' | 'cancelled' | 'locked' |
| current_period_end | timestamp nullable | End of paid period; null during trial (use tenants.trial_ends_at) |
| created_at | timestamp | |
| updated_at | timestamp |
5.8 TenantEntitlement
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| feature_key | string | e.g. 'sso', 'public_api', 'advanced_reports' |
| enabled | boolean | |
| created_at | timestamp | |
| updated_at | timestamp |
Unique (tenant_id, feature_key).
5.9 Notification
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| user_id | uuid | FK → users |
| type | string | e.g. 'ticket_assigned', 'sla_breach' |
| payload | jsonb | Contextual data (ticket_id, etc.) |
| read_at | timestamp nullable | |
| created_at | timestamp |
5.10 SlaPolicy
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| inbox_id | uuid nullable | FK → inboxes; null = tenant-wide default |
| name | string | e.g. "Standard SLA" |
| first_response_hours | int | Target first response time |
| resolution_hours | int | Target resolution time |
| business_hours_only | boolean | Whether to count only business hours |
| created_at | timestamp | |
| updated_at | timestamp |
5.11 AuditLog
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| user_id | uuid nullable | FK → users; null for system actions |
| action | string | e.g. 'user.invited', 'inbox.connected' |
| resource_type | string | e.g. 'inbox', 'user', 'ticket' |
| resource_id | uuid nullable | |
| metadata | jsonb | Additional context |
| created_at | timestamp |
5.12 Session
Managed by Lucia. Stored in DB.
| Field | Type | Notes |
|---|---|---|
| id | string | PK; random token (not UUID) |
| user_id | uuid | FK → users |
| expires_at | timestamp | Sliding expiry; max 90 days |
| created_at | timestamp |
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
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| name | string | e.g. 'billing', 'bug' |
| color | string | Hex color code e.g. '#ef4444' |
| created_at | timestamp | |
| updated_at | timestamp |
Unique (tenant_id, name). Junction: ticket_tags(ticket_id, tag_id, created_at). Unique (ticket_id, tag_id).
5.14 Invitation
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| string | Invitee email address | |
| role | enum | 'admin' | 'agent' | 'viewer' |
| token | string | Unique, random; used in invite link |
| expires_at | timestamp | e.g. now() + 7 days |
| accepted_at | timestamp nullable | Set when invite is accepted |
| invited_by_user_id | uuid | FK → users |
| created_at | timestamp |
5.15 Snippet (Canned Response)
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| name | string | Display name (e.g. "Welcome response") |
| body | text | Template body (plain text or HTML) |
| created_by_user_id | uuid | FK → users |
| created_at | timestamp | |
| updated_at | timestamp |
5.16 WebhookEndpoint
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| url | string | Customer-provided HTTPS endpoint |
| secret | string | Shared secret for HMAC signature verification (stored encrypted) |
| events | string[] | Subscribed event types e.g. ['ticket.created', 'ticket.closed'] |
| enabled | boolean | Can be toggled without deleting |
| created_at | timestamp | |
| updated_at | timestamp |
5.17 WebhookDelivery
Delivery log per webhook endpoint. Used for the "last delivery status" UI in settings and for debugging failed deliveries.
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| webhook_endpoint_id | uuid | FK → webhook_endpoints |
| tenant_id | uuid | FK → tenants |
| event | string | e.g. 'ticket.created' |
| event_id | string | Idempotency key from the event payload |
| payload | jsonb | Full JSON payload sent |
| response_status | int nullable | HTTP status code from customer endpoint; null if request failed to send |
| response_body | text nullable | First 1000 chars of response body |
| attempt | int | Attempt number (1 = first try) |
| succeeded | boolean | True if response_status is 2xx |
| delivered_at | timestamp nullable | When request was sent |
| created_at | timestamp |
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
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| name | string | Human label (e.g. "Zapier integration") |
| key_hash | string | SHA-256 hash of the key; never store plaintext |
| key_prefix | string | First 8 chars of key shown in UI for identification (e.g. ixk_a1b2) |
| last_used_at | timestamp nullable | Updated on each API request |
| revoked_at | timestamp nullable | Set on revoke; key is invalid when not null |
| created_by_user_id | uuid | FK → users |
| created_at | timestamp |
5.19 FeatureFlag
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| key | string | Unique flag identifier e.g. 'new_ticket_ui' |
| enabled_globally | boolean | Default value for all tenants |
| description | string nullable | Internal note on what this flag controls |
| created_at | timestamp | |
| updated_at | timestamp |
Per-tenant overrides: feature_flag_overrides(id, flag_key, tenant_id, enabled, created_at). Tenant override takes precedence over global value.
5.20 NotificationPreference
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| user_id | uuid | FK → users |
| tenant_id | uuid | FK → tenants; preferences are per-tenant |
| event_type | string | e.g. 'ticket_assigned', 'sla_breach' |
| email_enabled | boolean | Default true |
| in_app_enabled | boolean | Default true |
| created_at | timestamp | |
| updated_at | timestamp |
Unique (user_id, tenant_id, event_type). If no row exists for a user+tenant+event, defaults apply (both channels on).
5.21 BusinessHours
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| tenant_id | uuid | FK → tenants |
| timezone | string | IANA timezone e.g. 'Europe/Berlin' |
| monday_start | time nullable | null = closed all day |
| monday_end | time nullable | |
| tuesday_start | time nullable | |
| tuesday_end | time nullable | |
| wednesday_start | time nullable | |
| wednesday_end | time nullable | |
| thursday_start | time nullable | |
| thursday_end | time nullable | |
| friday_start | time nullable | |
| friday_end | time nullable | |
| saturday_start | time nullable | |
| saturday_end | time nullable | |
| sunday_start | time nullable | |
| sunday_end | time nullable | |
| created_at | timestamp | |
| updated_at | timestamp |
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):
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| message_id | uuid | FK → messages |
| tenant_id | uuid | FK → tenants |
| file_name | string | Original filename |
| storage_key | string | S3 object key (tenant-prefixed path) |
| content_type | string | MIME type |
| size_bytes | int | |
| created_at | timestamp |
ContactSubmission (see Section 2.13 Marketing website):
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| name | string | |
| string | ||
| company | string nullable | |
| subject | string | Dropdown value |
| message | text | |
| ip_address | string nullable | For rate limiting; hashed or truncated for GDPR |
| created_at | timestamp |
InboxAlias (see Section 5.4 Inbox):
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| inbox_id | uuid | FK → inboxes |
| string | Additional address that maps to this inbox | |
| created_at | timestamp |
Unique (inbox_id, email) per tenant scope as needed.
FeatureFlagOverride (see Section 5.19 FeatureFlag):
| Field | Type | Notes |
|---|---|---|
| id | uuid | PK |
| flag_key | string | FK or reference to feature_flags.key |
| tenant_id | uuid | FK → tenants |
| enabled | boolean | Override for this tenant |
| created_at | timestamp |
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
| Table | Index | Type | Purpose |
|---|---|---|---|
tenants | (slug) | Unique | Tenant resolution from path (slug) |
users | (email) | Unique | Login lookup |
tenant_memberships | (tenant_id, user_id) | Unique | Membership check |
tenant_memberships | (user_id) | Index | Load all tenants for a user |
sessions | (user_id) | Index | Invalidate all sessions for a user |
invitations | (token) | Unique | Token lookup on invite accept |
invitations | (tenant_id, email) | Index | Check for existing pending invite |
Ticket queries (most frequent, most critical)
| Table | Index | Type | Purpose |
|---|---|---|---|
tickets | (tenant_id, status, created_at DESC) | Index | Default ticket list (open tickets, newest first) |
tickets | (tenant_id, inbox_id, status) | Index | Per-inbox ticket list |
tickets | (tenant_id, assignee_id, status) | Index | My tickets filter |
tickets | (tenant_id, ticket_number) | Unique | Human ID lookup |
tickets | (tenant_id, updated_at DESC) | Index | Sort by last activity |
tickets | (sla_policy_id) WHERE status NOT IN ('resolved','closed') | Partial index | SLA cron job: open tickets with policy |
ticket_tags | (ticket_id) | Index | Load tags for a ticket |
ticket_tags | (tag_id) | Index | Filter tickets by tag |
Message queries
| Table | Index | Type | Purpose |
|---|---|---|---|
messages | (ticket_id, created_at ASC) | Index | Load thread in order |
messages | (inbox_id, message_id_header) | Unique | Deduplication on ingest |
messages | (tenant_id, send_status) WHERE direction = 'outbound' | Partial index | Find pending/failed outbound messages |
attachments | (message_id) | Index | Load attachments for a message |
Notifications and search
| Table | Index | Type | Purpose |
|---|---|---|---|
notifications | (user_id, tenant_id, read_at) | Index | Unread count + notification list |
notifications | (tenant_id, created_at DESC) | Index | Notification history |
tickets | (tenant_id, tsv) using GIN | GIN | Full-text search (tsv is a generated tsvector column from subject) |
messages | (tenant_id, tsv) using GIN | GIN | Full-text search on body_text |
Audit and billing
| Table | Index | Type | Purpose |
|---|---|---|---|
audit_logs | (tenant_id, created_at DESC) | Index | Audit log view |
subscriptions | (tenant_id) | Unique | Plan lookup |
tenant_entitlements | (tenant_id, feature_key) | Unique | Entitlement check |
api_keys | (key_hash) | Unique | API key authentication |
api_keys | (tenant_id) | Index | List keys for tenant |
webhook_endpoints | (tenant_id) | Index | Load 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.
| Step | Content | Done when |
|---|---|---|
| 1. Welcome | Org name confirmation, slug preview | User confirms org details |
| 2. Connect your first inbox | Choose connection type (Gmail / Microsoft / IMAP-SMTP) → OAuth or credential form → test connection | Inbox is connected and verified |
| 3. Invite your team | Enter email addresses and roles to invite (optional, skippable) | Invites sent or step skipped |
| 4. Done | Summary 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_aton 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.
- Choose connection type: Gmail / Microsoft 365 / IMAP-SMTP
- Gmail / Microsoft: Show what permissions will be requested → "Connect with Google / Microsoft" button → OAuth redirect → callback → tokens stored → inbox marked connected.
- IMAP/SMTP: Form with IMAP host, port, security, username, password; SMTP host, port, security, username, password. "Test connection" button validates before save.
- 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:
| Feature | Included |
|---|---|
| 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
idand 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
@Namewith a highlight in the UI; clicking it does nothing (no profile pages).
6.7 Attachment limits
| Limit | Value | Notes |
|---|---|---|
| Max file size per attachment | 25 MB | Aligned with Gmail's limit; SMTP providers vary but 25 MB is a safe ceiling |
| Max attachments per message | 10 files | Prevents abuse; sufficient for normal support use |
| Max total size per message | 25 MB | Across all attachments combined |
| Allowed file types | All types allowed | No whitelist; virus scan deferred to future |
| Inbound attachment storage | Stored as-is | No processing; original file stored in S3 |
| Outbound attachment storage | Stored in S3 before send | Agent 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.
| State | URL / Location | What 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 found | Path resolves to unknown slug | "This workspace doesn't exist" with link to sign in or create org |
| Unauthorized (no session) | Middleware redirect | Redirect 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) | Inline | In-page error message; not a full redirect |
| Account locked (post-trial, no subscription) | /app/[slug]/locked | Banner + CTA to subscribe; read-only access to tickets; no reply, no sync |
| Trial expired banner | In-app (persistent) | Sticky top banner: "Your trial ended X days ago. Subscribe to continue." Dismissible only after subscribing |
| Maintenance mode | /maintenance | Static 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 failed | Inline in thread | Error 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:
| Category | Commands |
|---|---|
| Navigation | Go 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 actions | New ticket (manual creation), Search tickets, Invite team member |
| Settings shortcuts | Manage inboxes, Manage team, Billing & plan |
| User | Toggle 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:
| Library | Purpose | Used for |
|---|---|---|
| shadcn/ui | Core UI primitives | Buttons, inputs, selects, dialogs, dropdowns, tables, badges, toasts, forms, tabs, cards |
| Tiptap | Rich text editor | Reply composer, internal note composer |
| cmdk | Command palette | Cmd+K global command menu (bundled with shadcn/ui) |
| Recharts | Charts | Reporting page (bar charts, line charts) |
| react-hook-form + Zod | Forms and validation | All forms (signup, settings, inbox connect, invite) |
| TanStack Table | Data tables | Ticket list, team member list, audit log |
| date-fns | Date formatting | Ticket timestamps, SLA countdowns, relative time ("2 hours ago") |
| DOMPurify | HTML sanitization | Inbound email rendering |
| next-themes | Dark/light mode | Theme provider, system preference detection, FOUC prevention |
| sonner | Toast notifications | Action feedback (ticket saved, reply sent, error, etc.) |
| @tanstack/react-query | Client-side data fetching | tRPC 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?redirectparam 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]/onboardingon 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_membershipcreated → redirect to/app/[slug]/tickets(slug = invited tenant). Token markedaccepted_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:
Cardwith 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_atis 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 usingTicketRowcomponent.TicketRow— Subject, inbox badge, status badge (Badge), priority indicator, assignee avatar (Avatar), relative timestamp.FilterBar—Selectcomponents for status, assignee, inbox, priority, tag; date range picker (DateRangePickerfrom shadcn).SearchInput— debounced input triggering FTS query.BulkActionBar— appears when rows are selected;Selectfor status change,Comboboxfor assignee,Buttonfor 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(inlineSelect),AssigneeSelect(inlineComboboxwith agent search),PrioritySelect.MessageThread— scrollable list ofMessageBubblecomponents. Inbound vs outbound visually distinct. Internal notes withBadge"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(Popoverwith list),ButtonSend.TicketSidebar— Tags (TagInputmulti-selectCombobox), SLA countdown (SlaTimer), created/updated timestamps, ticket move (Selectfor 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]/ticketsbut 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, useCombobox),ButtonSave. Danger Zone section withButtonDelete Organization (opensAlertDialogwith type-to-confirmInput). - 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 (Badgein green/yellow/red), "Settings" and "Disconnect" actions. "Add inbox"Button→ opens inbox connect wizard (Dialogor/settings/inboxes/newroute). 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 (
Tabsshadcn): "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 (Selectinline for role change), "Remove" action (AlertDialogconfirm).- "Invite member"
Button→Dialogwith emailInput+ roleSelect+ 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"
Button→DialogwithInputfor name/hours,Selectfor inbox (or "All inboxes"),Switchfor business hours only. - Business hours section: timezone
Combobox, per-day rows withSwitch(open/closed) +TimePickerstart/end (pair ofSelector 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" →Dialogwith nameInput+ 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" →Dialogwith URLInput, 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, revokeButton. "Create API key" →Dialogwith nameInput→ shows full key once in a copyableInputwith warning "You won't see this again". - Role access: Admin only. Gated by
public_apientitlement.
/app/[slug]/settings/sso
- Purpose: SSO configuration (SAML/OIDC).
- Components:
Formwith IdP metadata URL or XML upload, attribute mappingInputs, "Test SSO" button, enable/disableSwitch. - Role access: Admin only. Gated by
ssoentitlement.
/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.InboxFilter—Selectto 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:
Switchfor email and in-app per event type), "Appearance" (theme toggle: light/dark/system usingnext-themes).
- Tabs: "Account" (name, email, change password), "Notifications" (per-event toggle matrix:
- 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 ofNotificationItems: 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:
Cardwith 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:
| Pattern | Implementation |
|---|---|
| Confirmation dialogs | AlertDialog (shadcn) for all destructive actions. Always require explicit user action (click confirm or type name). |
| Toast notifications | sonner for action feedback: success (green), error (red), info (blue). Max 3 visible at once. Auto-dismiss 4s, error stays until dismissed. |
| Loading states | Skeleton components matching the shape of the content being loaded. No full-page spinners except initial app load. |
| Empty states | Every 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 validation | Inline error messages below each field (react-hook-form + zod). Submit button disabled while submitting. Error summary at top for server errors. |
| Optimistic updates | Ticket 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 shortcuts | Cmd+K for command palette. Cmd+Enter to send reply. Escape to close dialogs. Tab navigation throughout. |
| Responsive breakpoints | sm (640px): mobile. md (768px): tablet — sidebar collapses. lg (1024px): desktop — 2-column layout. xl (1280px): wide — 3-column ticket view. |
| Role-based UI | Actions 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 features | Features 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:
| Data | Storage 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 tokens | Random, stored in sessions table (Lucia); HTTP-only cookies; short-lived with refresh |
| Stripe keys | Environment variables only; never in DB |
| Postmark key | Environment 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
tenantIdfrom 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:
- 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.
- 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 type | Mechanism | Target latency | Notes |
|---|---|---|---|
| Gmail API | Gmail Push (Pub/Sub) + fallback poll | < 30 seconds | Push 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 Graph | Graph change notifications + fallback poll | < 30 seconds | Change notifications via Graph subscriptions; worker fetches on notification. Fallback: poll every 5 minutes. |
| IMAP | Poll every 5 minutes | < 5 minutes | IMAP 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):
- Create a Google Cloud Pub/Sub topic (e.g.
inboxops-gmail-push-prod). - Grant
gmail-api-push@system.gserviceaccount.comtheroles/pubsub.publisherrole on the topic. - 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, extractshistoryIdandemailAddress, and enqueues an Inngest "fetch new messages" job for the matching inbox. - 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 anexpirationtimestamp (always ≤ 7 days from now).- Store
expirationininboxes.gmail_watch_expires_at(new field). - A daily Inngest cron job checks for inboxes where
gmail_watch_expires_at < now() + 24hand callsusers.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_idandexpirationDateTimeininboxes.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 IDgraph_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 type | State field |
|---|---|
| Gmail | inboxes.gmail_history_id — last processed Gmail history ID |
| Microsoft Graph | inboxes.graph_delta_token — delta token for incremental sync |
| IMAP | inboxes.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
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | ✅ | PostgreSQL connection string (Neon); includes pooler URL for web, direct URL for migrations |
DATABASE_URL_UNPOOLED | ✅ | Direct (non-pooled) Neon connection; used for migrations and Drizzle Studio |
9.2 Auth
| Variable | Required | Description |
|---|---|---|
AUTH_SECRET | ✅ | Random secret used by Lucia for session signing; min 32 chars; generate with openssl rand -hex 32 |
9.3 Encryption
| Variable | Required | Description |
|---|---|---|
ENCRYPTION_KEY | ✅ | AES-256 key for encrypting inbox credentials and OAuth tokens; base64-encoded 32-byte key |
9.4 Email: Google (Gmail API)
| Variable | Required | Description |
|---|---|---|
GOOGLE_CLIENT_ID | ✅ | OAuth2 client ID from Google Cloud Console |
GOOGLE_CLIENT_SECRET | ✅ | OAuth2 client secret |
GOOGLE_REDIRECT_URI | ✅ | OAuth2 callback URL (e.g. https://inboxops.app/api/auth/google/callback) |
GOOGLE_PUBSUB_TOPIC | ✅ | Full Pub/Sub topic name e.g. projects/inboxops/topics/gmail-push-prod; used when registering inbox watches |
9.5 Email: Microsoft (Graph API)
| Variable | Required | Description |
|---|---|---|
MICROSOFT_CLIENT_ID | ✅ | App registration client ID from Azure Portal |
MICROSOFT_CLIENT_SECRET | ✅ | App registration client secret |
MICROSOFT_REDIRECT_URI | ✅ | OAuth2 callback URL (e.g. https://inboxops.app/api/auth/microsoft/callback) |
MICROSOFT_TENANT_ID | ✅ | common for multi-tenant apps (supports any Microsoft account) |
GRAPH_PUSH_NOTIFICATION_URL | ✅ | Public URL for Graph change notification deliveries e.g. https://inboxops.app/api/webhooks/graph-push |
9.6 Transactional email (Postmark)
| Variable | Required | Description |
|---|---|---|
POSTMARK_API_KEY | ✅ | Server API token from Postmark; used for invite, password reset, and system notification emails |
POSTMARK_FROM_ADDRESS | ✅ | Verified sender address (e.g. noreply@inboxops.app) |
9.7 Billing (Stripe)
| Variable | Required | Description |
|---|---|---|
STRIPE_SECRET_KEY | ✅ | Stripe secret key (sk_live_... or sk_test_...); server-only |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY | ✅ | Stripe publishable key; exposed to the browser for Stripe.js — must have NEXT_PUBLIC_ prefix |
STRIPE_WEBHOOK_SECRET | ✅ | Webhook signing secret from Stripe dashboard; used to verify incoming webhooks |
STRIPE_STARTER_PRICE_ID | ✅ | Stripe Price ID for the Starter plan |
STRIPE_GROWTH_PRICE_ID | ✅ | Stripe Price ID for the Growth plan |
STRIPE_BUSINESS_PRICE_ID | ✅ | Stripe 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)
| Variable | Required | Description |
|---|---|---|
INNGEST_EVENT_KEY | ✅ | Inngest event key for sending events from the app |
INNGEST_SIGNING_KEY | ✅ | Inngest signing key for verifying webhook payloads from Inngest cloud |
9.9 Object storage (AWS S3)
| Variable | Required | Description |
|---|---|---|
AWS_ACCESS_KEY_ID | ✅ | IAM user access key; policy must allow S3 read/write on the attachments bucket only |
AWS_SECRET_ACCESS_KEY | ✅ | IAM user secret key |
AWS_REGION | ✅ | S3 bucket region (e.g. eu-central-1) |
AWS_S3_BUCKET | ✅ | Bucket name (e.g. inboxops-prod or inboxops-staging) |
9.10 Application
| Variable | Required | Description |
|---|---|---|
APP_URL | ✅ | Public base URL (e.g. https://inboxops.app); used for redirect URIs, email links, and OAuth callbacks |
NODE_ENV | ✅ | production, staging, or development |
LOG_LEVEL | — | Logging verbosity; default info; set to debug in development |
9.11 Error tracking (Sentry)
| Variable | Required | Description |
|---|---|---|
SENTRY_DSN | ✅ | Sentry DSN for apps/web and apps/worker; used by both apps (same Sentry project) |
NEXT_PUBLIC_SENTRY_DSN | ✅ | Same DSN exposed to the browser for client-side error capture; must have NEXT_PUBLIC_ prefix |
SENTRY_AUTH_TOKEN | ✅ | Auth token for uploading source maps at build time (CI only; not needed at runtime) |
SENTRY_ORG | ✅ | Sentry organization slug (CI only) |
SENTRY_PROJECT | ✅ | Sentry project slug (CI only) |
9.12 Marketing site
| Variable | Required | Description |
|---|---|---|
CONTACT_EMAIL | ✅ | Recipient address for contact form submissions (e.g. hello@inboxops.app) |
NEXT_PUBLIC_PLAUSIBLE_DOMAIN | — | Domain for Plausible Analytics (e.g. inboxops.app); omit in development to disable analytics |
9.13 Local development notes
- Copy
.env.exampleto.env.localand fill in values. - For Google and Microsoft OAuth, register
http://localhost:3000/api/auth/google/callbackand/microsoft/callbackseparately 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 (
ngrokorcloudflared) in local development, or disable push and rely on the 5-minute fallback poll (setDISABLE_PUSH_NOTIFICATIONS=true). - Sentry: use the
developmentenvironment; errors are captured but shown in a separate "dev" stream. Source map upload is skipped locally. - Marketing pages (
/,/pricing, etc.) are available locally atlocalhost:3000alongside the app atlocalhost: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:
- Transfer open tickets to another inbox (tenant selects the target inbox from a dropdown).
- 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_idon 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_membershipsrecord. - 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
usersrecord and alltenant_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_tagsrows). - 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_idpointing to the deleted policy have it set tonull(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:
- Stripe subscription is cancelled via API.
- All DB records are hard-deleted: inboxes, tickets, messages, users (if no other memberships), subscriptions, entitlements, audit log.
- S3 attachments are queued for async batch deletion.
- This action is irreversible. No recovery after tenant deletion.
10.11 Summary table
| Entity | Edit | Disconnect | Delete | What happens to dependent data |
|---|---|---|---|---|
| Inbox | ✅ Name, settings, credentials | ✅ Stops sync; history preserved | ✅ Admin only | Open tickets: transfer or close; closed tickets: preserved read-only |
| Ticket | ✅ Subject, status, assignee, inbox | — | ✅ Admin only | Messages, attachments: hard deleted |
| Message | — | — | ✅ Admin only (GDPR) | Attachments: hard deleted |
| Team member | ✅ Role | — | ✅ Remove from tenant | Assigned tickets remain; optional reassign prompt |
| Tag | ✅ Name, color | — | ✅ | Removed from all tickets |
| Snippet | ✅ Name, body | — | ✅ | None |
| SLA Policy | ✅ All fields | — | ✅ | Affected tickets: sla_policy_id → null |
| Webhook endpoint | ✅ URL, secret, events | — | ✅ | Pending jobs cancelled |
| API key | — | — | ✅ Revoke | Immediate invalidation |
| Tenant | ✅ Name, slug, timezone | — | ✅ Type-to-confirm | All 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.