Used make_interval(mins => ...)::int and added allowlist filter to prevent malformed values from reaching SQL.
Added workspace-scoped ownership verification: all contactIds must belong to the user's active workspace.
Added workspace-scoped ownership verification for single contact settings endpoint.
Moved token to POST request body instead of query parameter.
The OAuth state parameter contains base64url-encoded { userId, workspaceId, nonce }. These are random UUIDs (not secrets), CSRF protection is intact via cookie comparison, and keeping IDs visible aids debugging. Not a meaningful security risk.
Added workspace filtering to the GET endpoint so it only returns settings for connections in the active workspace.
Added Graphile Worker jobKey + jobKeyMode: "preserve_run_at" to deduplicate sync jobs per connection.
Added 60-second cooldown between manual syncs, returning 429 if triggered too frequently.
Fixed condition from (!existing || (!existing && addr.name)) to (!existing || (existing === "" && addr.name)).
Added res.ok check and email presence validation after fetching user info from Google.
Changed column from text to integer in schema, updated Zod schema to use numeric literals, simplified dispatch SQL (no more ::int cast needed), and generated migration.
Removed the dead URLSearchParams variable.
Added toggle buttons for boolean settings and a dropdown for sync interval. Wired up onUpdate to call the settings API and refresh the connection list.
Batch-loads existing contacts and workspace memberships upfront (2 queries total), then only does individual queries for new contact creation and workspace linking.
Implemented Gmail batch API (POST /batch/gmail/v1) with multipart/mixed request/response parsing. Fetches up to 50 message metadata per single HTTP request instead of individual calls. Applied to both fetchSentToContacts and fetchMessagesSince.
Added error states and user-visible error messages for both connection and contact fetch failures.
Fixed to properly add/remove only filtered contacts from selection, preserving selections from other filter states. Also fixed the checkbox checked state to use every() check.
Added .toLowerCase().trim() when building the email-to-contact map from DB records.
The GET /api/email-connections/:id/contacts endpoint fetches ALL contacts with no pagination. Needs API design decision on pagination parameters.
Pattern is inconsistent across files. Low risk since current code paths are guarded, but could bite during future changes.
Encoded workspaceId in the OAuth state parameter during initiation and read it back in the callback, instead of relying on cookies surviving the redirect.
Strengthened the dedup query to also match on emailConnectionId, reducing risk of false matches. Race condition mitigated by bug #7 fix (job deduplication).
Changed to static import alongside other drizzle-orm imports.
File: src/lib/auth.ts:21
The Google OAuth provider was configured with allowDangerousEmailAccountLinking: true, allowing automatic account linking when a new Google sign-in matches an existing user's email. An attacker who controls a Google account with a victim's email could log in as the victim. Removed the flag.
File: src/app/api/webhooks/mailersend/route.ts
The POST endpoint had zero authentication. Anyone could forge delivery events, poison campaign metrics, or force-unsubscribe contacts. Added HMAC-SHA256 signature verification via MAILERSEND_WEBHOOK_SIGNING_SECRET env var.
File: src/app/api/webhooks/tally/route.ts
The POST endpoint had no authentication. Anyone could create/update contacts with arbitrary data. Added HMAC-SHA256 signature verification via TALLY_WEBHOOK_SIGNING_SECRET env var. Also fixed new contacts being created without workspace association — they are now linked to the global workspace.
File: src/app/api/contacts/[id]/route.ts:17-31
The endpoint only checked requireAuth(). Any logged-in user could read any contact by ID. Added workspace-scoped lookup via getContactForWorkspace() (INNER JOIN on contact_workspaces). Global admins bypass the check.
File: src/app/api/contacts/[id]/route.ts:35-73
Same pattern as #27 but for writes. Only checked requireMember() (global role). Added workspace-scoped verification and upgraded to requireWorkspaceMember (workspace-aware role check).
File: src/lib/workspace-context.ts:10-27
Meta-bug: getActiveWorkspaceId() accepts workspace IDs from header/query/cookie without membership validation. Fixed by ensuring every endpoint that uses it also calls requireWorkspaceMember/requireWorkspaceAdmin. Individual fixes applied in #27, #28, #30, #31.
File: src/app/api/workspaces/[id]/route.ts:8-18
Only checked checkAuth(). Any authenticated user could read any workspace's details. Added requireWorkspaceMember check — only workspace members and global admins can view workspace details.
File: src/app/api/workspaces/[id]/members/route.ts
Only checked checkAuth(). Any authenticated user could list all members of any workspace. Added requireWorkspaceMember check.
Files: src/lib/api-auth.ts, src/db/schema/api-keys.ts, src/lib/users.ts, src/app/api/api-keys/route.ts, src/app/api/api-keys/[id]/route.ts, src/components/api-keys-manager.tsx
checkAuth() now looks up the user's actual role from the database instead of hardcoding "admin". API keys are workspace-scoped: each key has a workspace_id column, keys are created in the active workspace, listed per-workspace (with creator name/email), and workspace admins can manage all keys in their workspace. The API key holder passes X-Workspace-Id per-request, and effective role is checked the same way as session auth.
File: src/app/api/workspaces/[id]/route.ts:21-33
Body was passed directly to updateWorkspace() without validation. Added UpdateWorkspaceInput Zod schema. Also downgraded from global-admin-only to workspace-admin (more appropriate for workspace self-management).
File: src/db/schema/connections.ts:17-19
Connection credentials were stored as plaintext JSONB. Created src/lib/credentials-encryption.ts with encryptCredentials/decryptCredentials helpers using AES-256-GCM (same EMAIL_ENCRYPTION_KEY). Encrypt on create/update, decrypt on read. Backward compatible with any pre-encryption rows.
File: src/app/api/webhooks/mailersend/route.ts:119-149
handleWebhookUnsubscribe() set prefs[cat.name] instead of the correct prefs["workspaceId:categoryName"] format. Now fetches the campaign's workspaceId and uses the correct namespaced key.
File: src/app/api/webhooks/tally/route.ts:183-189 — FIXED as part of #26
File: src/lib/sync-engine.ts:263-309
Replaced check-then-act with atomic ON CONFLICT DO UPDATE upsert (for update strategy) and ON CONFLICT DO NOTHING + re-select (for skip strategy). No more race window.
File: src/lib/sync-engine.ts:396-406
Replaced select-then-insert with ON CONFLICT DO NOTHING + re-select pattern. If two concurrent syncs try to create the same tag, one inserts and the other gets the existing row.
File: src/lib/sync-engine.ts:401
applyTagsToContact() now accepts workspaceId and filters/creates tags scoped to the connection's workspace.
File: src/lib/unsubscribe-tokens.ts:34, src/lib/ticket-unsubscribe-tokens.ts:31
Both verify functions now log console.error when UNSUBSCRIBE_SECRET is missing, making the misconfiguration visible in server logs. Still returns false (safe default) but no longer silent.
Files: src/db/schema/campaigns.ts, src/lib/campaigns.ts, src/lib/schemas/campaigns.ts, src/components/campaign-manager.tsx, src/app/api/campaigns/unsubscribe-status/route.ts
When buildUnsubscribeUrl() throws (secret not configured), the email was sent silently with an empty unsubscribe merge variable. Three-layer fix:
- Database: New
allow_no_unsubscribeboolean column on campaigns (defaultfalse). - UI (save-time warning): When creating or editing a categorized campaign without a working unsubscribe mechanism, a modal dialog explains which mechanisms are missing, the CAN-SPAM/GDPR risks, and advises the user to fix the issue. "Go Back & Fix" returns to the editor; "Save Anyway" sets
allowNoUnsubscribe: trueon the campaign. - Server-side enforcement:
sendCampaign()refuses to send a categorized campaign without a working unsubscribe mechanism unlessallowNoUnsubscribeistrue. Resets status todraftand throws an error. If the flag is set, proceeds with aconsole.warnfor logging. NewGET /api/campaigns/unsubscribe-statusendpoint returns server-side infrastructure status (secret configured, List-Unsubscribe setting) for the UI check.
File: src/lib/contacts.ts
When called without workspaceId, now returns only core fields (safe default) instead of all fields from all workspaces.
File: src/lib/sync-engine.ts:252-327
For each record: 1 query to find existing contact, 1 insert/update, 1 workspace link insert, plus N queries per tag.
File: src/lib/contacts.ts
These functions return any contact regardless of workspace. Used from API routes where workspace isolation should be enforced. Partially addressed by #27/#28 (route-level fix), but the functions themselves remain unscoped for sync engine use.
Files: src/db/schema/contacts.ts, src/db/schema/tags.ts, src/db/schema/connections.ts, src/db/schema/campaigns.ts, src/db/schema/segments.ts
Multiple tables have workspaceId as a plain UUID without FK or onDelete cascade.
File: src/lib/sync-engine.ts:269-278
When duplicateStrategy is "skip" and a contact already exists, the code still inserts a workspace link — silently adding it to the syncing workspace.
File: src/db/schema/segments.ts:30
Dead schema flag — no code reads or enforces it.
File: src/db/schema/connections.ts:70-72
Both fields control activation. Can become contradictory.
Carried forward from bug #19 — still not addressed.
Full audit report: docs/audits/api-blackbox-audit-2026-04-04.md
Systemic bug. requireWorkspaceMember() used getEffectiveRole() which returned member for any user with a global member role, even without workspace membership. Added explicit hasWorkspaceMembership() check. Applied workspace membership enforcement to all 18+ API route files.
Campaigns could reference segments from other workspaces, sending emails to contacts the workspace shouldn't access. Added segmentId cross-workspace validation on campaign create and update.
Tags from one workspace could be assigned to contacts in another workspace. Added tag workspace validation on POST/DELETE contact tag endpoints.
API keys have a workspaceId but it was not enforced. getActiveWorkspaceId() now defaults to the API key's workspace when no header is specified. Workspace membership enforcement (bug #50) prevents cross-workspace access.
File: src/lib/mailersend.ts
renderTemplate() performed naive string substitution without HTML escaping. Contact names or custom fields containing HTML would be injected directly into email bodies. Added escapeHtml() function to escape all merge variable values.
All updatedAt: new Date() calls (20+ instances across 11 files) replaced with sql\now()`` for consistent UTC timestamps.
File: src/lib/segments.ts
The buildColumnCondition switch statement only had is_set/is_not_set. Added is_empty/is_not_empty as aliases.
API docs said contacts, implementation returned sample. Renamed to contacts in both backend and frontend.
Added .max(200) for name, .max(998) for subject (RFC 2822), .max(500_000) for body in Zod schemas.
POST /api/campaigns/:id/send returns { queued: true } for a categorized campaign that will be rejected by the worker. Low impact — status resets to draft correctly.
Schema and queries are correctly aligned. Likely a runtime/environment issue.