feat: custom inbound mail domains, rot/rut payout file, invoice email texts, security hardening (#878)
* fix(security): guard MCP test keys, RLS role gate + voucher RPC guards, /api MFA gate, deps - MCP: force dry-run / block writes for test-mode API keys in tools/call (extensions/general/mcp-server) - DB: current_user_can_write role gate on write policies (40 tables) + tenant guards, SET search_path, REVOKE anon on commit_journal_entry / next_voucher_number / detect_voucher_gaps (migration 20260702093000) - Middleware: MFA (AAL2) gate on cookie-authenticated /api routes via apiPathSkipsMfaGate - Deps: npm audit fix clears mailparser/linkify-it/nodemailer/svix/uuid highs; xlsx -> SheetJS 0.20.3 Adds unit + pg-real tests. Does not touch in-progress ROT/RUT or invoice-email-texts work. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): rot/rut begäran om utbetalning — HUS XML (V6), payout tracking + settlement, MCP tool Generates Skatteverkets begäran-om-utbetalning file (schema V6) from paid ROT/RUT invoices — no submission API exists, the file is uploaded manually at skatteverket.se. Headless by design for now: API routes + MCP tool (gnubok_generate_rot_rut_file), no UI surfaces. - lib/invoices/rot-rut-file.ts: pure XML generator with deterministic per-invoice blockers (hours, work type, personnummer, property info, mixed rot+rut, XSD limits) + 31 January deadline warnings - rot_rut_payout_requests(+items) tables: one active begäran per invoice (DB triggers incl. reactivation guard), RLS, audit, pg-real tests - Settlement: POST /settle books debit 1930 / credit 1513 via the engine (source_type rot_rut_payout); partial payouts → partially_paid - Work-type lists corrected against Begaran.xsd: IT-tjänster is rut-only, snöskottning/tillsyn/tvätt added (schablontjänster utfört-only) - Fix: invoice-level fastighetsbeteckning was validated but never persisted — now stamped onto rot lines in build-invoice-write; API accepts bostadsrätt pair (lägenhetsnr + BRF orgnr, editor UI deferred) - invoice_items.brf_org_number migration + MCP scope invoices:write Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): per-company editable invoice email texts Add an "E-posttexter" section under Settings -> Fakturering where the subject, greeting, body and sign-off of the standard invoice email can be customized per company in Swedish and English. Fields pre-fill with the standard texts and only diffs from the standard are stored (company_settings.invoice_email_texts JSONB), so future improvements to the stock wording still reach companies that have not customized. Each field has a reset-to-standard button; cleared fields snap back. Texts support a fixed placeholder set (invoice number, customer name, first name, company, due date, amount) substituted at send time in a single pass; unknown placeholders stay literal. Custom texts are HTML-escaped after substitution, newlines become <br> in the HTML variant, and subject lines are flattened to a single header line. Overrides apply to standard invoices only - credit notes, proforma and delivery notes keep the stock texts. All send paths (UI, v1 API, MCP approval, recurring) pick the texts up via the existing settings row. The Zod schema half of this change (InvoiceEmailTextsSchema in lib/api/schemas.ts) was inadvertently included in 8291f745. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(documents): accept PDFs with preamble before %PDF- header, surface content rejections as 400 detectFileMagic required the %PDF- signature at byte 0 (BOM aside), rejecting genuine PDFs that carry a leading newline or junk bytes — files every ISO 32000 reader opens fine. Now scan the first 1024 bytes for the signature, matching real-reader behavior. Image types stay strict at offset 0 to keep the anti-placeholder defense tight. Magic-byte rejections were also mislabeled as DOC_UPLOAD_STORAGE_FAILED (500 'Filen kunde inte sparas'), blaming storage for a client-side file problem. Both upload routes now map them to a new DOC_UPLOAD_INVALID_CONTENT (400) with an accurate message. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): full keyboard flow for manual journal entry Enter now drives the whole verifikat flow: verifikationstext drops into the first row missing an account, konto commits advance to debet, Enter on an empty debet hops to kredit, and an entered amount jumps to the next row. Once the voucher balances, Enter opens the review (unchanged gate) and the auto-focused confirm posts it — including through the no-underlag warning dialog. Escape in the inline review goes back to the form. Also fixes an Enter footgun in AccountCombobox: a bare Enter on a freshly focused field no longer selects the first account in the list — selection now requires typing or arrow navigation; otherwise Enter re-commits the current value or bubbles to the form-level handler. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: add custom inbound domains management for companies - Implemented functionality to allow companies to claim and manage their own inbound email domains via Resend's API. - Created a new table `company_inbound_domains` to store domain information, including status and DNS records. - Added necessary RLS policies to restrict access based on user roles (owner/admin). - Developed functions for domain normalization, validation, claiming, verification, and removal. - Implemented webhook handling for domain status updates from Resend. - Added comprehensive tests for RLS, constraints, and triggers related to the new domain management feature. * fix: address PR #878 review findings and CI failures - migrations: drop the ai_usage_tracking policy block from the role-gate migration — the table was removed by 20260504120000_remove_ai_subsystem and only lingers on staging as drift; a from-scratch chain (pg-real, Supabase preview) failed on it - invoice-inbox: never flip a custom domain to verified off a domain.updated webhook alone — confirm the receiving capability with Resend first (fail-closed); normalize both sides of the orphan-adoption domain match - rot/rut: block files where begärt belopp exceeds what the buyer paid (DEDUCTION_EXCEEDS_PAYMENT); tighten brf_org_number validation to real orgnr shapes; parameterize the settlement bank account (19xx, default 1930) - rot/rut routes: log acting user on financial mutations, stop swallowing item mirror errors, narrow response projections (no customer ids through the invoice join); document the deliberate inline-XML decision - documents: stop echoing raw storage-layer error messages to clients Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: round-2 CI + compliance findings on PR #878 - migrations: the role-gate migration targeted automation_webhooks, which 20260515170000_webhooks_v2 renamed to webhooks on the canonical chain (staging kept the old name — drift); gate public.webhooks instead, dropping legacy schema-sync policy names defensively. Restore the 20260623130000 owner fallback in next_voucher_number that the stale copied-verbatim body silently reverted (caught by engine.pg locally). Full migration chain verified from scratch against supabase/postgres:15. - mcp: bump the tools/list payload ceiling 44K -> 45K — main's #877 qualified-identifier schemas plus this branch's rot/rut tool crossed the ceiling only in combination; documented in the test's history log. - rot/rut: refuse partial settlement before Skatteverkets beslut is recorded (would bypass the PATCH lifecycle and strand the request); block zero-kronor ärenden (ZERO_DEDUCTION); require sekelsiffra 16 on 12-digit brf orgnr in both schema validation and normalizeBrfOrgNr Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: rename branch migrations off main's colliding versions After the merge with main, two versions were shared by two files each (20260702100000: rot_rut_payout_requests vs company_settings_dimensions_ enabled; 20260702130000: invoice_email_texts vs pending_operations_add_ create_dimension_value). psql-based CI applies by filename and doesn't care, but Supabase branching records migrations by version (PK) — the second file with the same version breaks the preview with a schema_migrations_pkey duplicate. Neither branch migration is version- recorded on staging or prod, so renaming to fresh 20260703 versions is safe; nothing between the old and new positions depends on these objects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): scope the /api MFA-gate bypass to real Bearer-auth surfaces Any Authorization header — attacker-controlled — used to skip the AAL2 gate for every /api route, so a stolen-password AAL1 cookie session could reach cookie-authenticated routes (which ignore the header) by attaching `Authorization: x`. The skip is now scoped to the surfaces whose auth contract IS the header (/api/v1 API keys, the MCP endpoint's OAuth tokens); pure Bearer callers elsewhere (cron secret, signed webhooks) carry no cookie session and were never touched by the gate, which only fires for cookie users. Superagent P2 on PR #878. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: normalize path separators in dimension statutory guard scan The route scan compared walked file paths against a POSIX-path allowlist, so the suite failed on Windows (backslash separators) while passing on Linux CI. Normalize the scanned paths to forward slashes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
678f2ccffd
commit
237b77a366
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
// The custom-domain routes are gated off by default (product decision
|
||||
// 2026-07-02) — enable the flag for the behavior tests, and prove the gate
|
||||
// itself in the last describe.
|
||||
beforeEach(() => {
|
||||
process.env.INBOX_CUSTOM_DOMAINS_ENABLED = 'true'
|
||||
})
|
||||
afterEach(() => {
|
||||
delete process.env.INBOX_CUSTOM_DOMAINS_ENABLED
|
||||
})
|
||||
|
||||
const claimMock = vi.fn()
|
||||
const verifyMock = vi.fn()
|
||||
const removeMock = vi.fn()
|
||||
const getMock = vi.fn()
|
||||
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/custom-domains', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('@/extensions/general/invoice-inbox/lib/custom-domains')
|
||||
>('@/extensions/general/invoice-inbox/lib/custom-domains')
|
||||
return {
|
||||
...actual,
|
||||
claimCustomDomain: (...args: unknown[]) => claimMock(...args),
|
||||
checkCustomDomainVerification: (...args: unknown[]) => verifyMock(...args),
|
||||
removeCustomDomain: (...args: unknown[]) => removeMock(...args),
|
||||
getCustomDomain: (...args: unknown[]) => getMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/rate-limits/inbox', () => ({
|
||||
checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
function findRoute(method: string, path: string) {
|
||||
return invoiceInboxExtension.apiRoutes!.find(
|
||||
(r) => r.method === method && r.path === path
|
||||
)!
|
||||
}
|
||||
|
||||
function buildCtx(supabase: unknown, overrides: Partial<ExtensionContext> = {}): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'invoice-inbox',
|
||||
supabase: supabase as ExtensionContext['supabase'],
|
||||
emit: vi.fn(),
|
||||
settings: { get: vi.fn(), set: vi.fn() },
|
||||
storage: { from: vi.fn() } as unknown as ExtensionContext['storage'],
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as ExtensionContext['log'],
|
||||
services: {},
|
||||
...overrides,
|
||||
} as ExtensionContext
|
||||
}
|
||||
|
||||
const DOMAIN_ROW = {
|
||||
id: 'row-1',
|
||||
company_id: 'company-1',
|
||||
domain: 'hansbolag.example',
|
||||
status: 'pending',
|
||||
resend_domain_id: 'rd_1',
|
||||
dns_records: [],
|
||||
verified_at: null,
|
||||
last_checked_at: null,
|
||||
}
|
||||
|
||||
describe('GET /inbox/domain', () => {
|
||||
const route = findRoute('GET', '/inbox/domain')
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns 401 without context', async () => {
|
||||
const res = await route.handler(createMockRequest('/inbox/domain'), undefined)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns the current domain row (or null)', async () => {
|
||||
getMock.mockResolvedValue(DOMAIN_ROW)
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const res = await route.handler(createMockRequest('/inbox/domain'), buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof DOMAIN_ROW }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.domain).toBe('hansbolag.example')
|
||||
expect(getMock).toHaveBeenCalledWith(expect.anything(), 'company-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /inbox/domain', () => {
|
||||
const route = findRoute('POST', '/inbox/domain')
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns 403 for non-admin members', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'member' } })
|
||||
const request = createMockRequest('/inbox/domain', {
|
||||
method: 'POST',
|
||||
body: { domain: 'hansbolag.example' },
|
||||
})
|
||||
const res = await route.handler(request, buildCtx(supabase))
|
||||
expect(res.status).toBe(403)
|
||||
expect(claimMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claims the domain for admins', async () => {
|
||||
claimMock.mockResolvedValue({ ok: true, data: DOMAIN_ROW })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'admin' } })
|
||||
const request = createMockRequest('/inbox/domain', {
|
||||
method: 'POST',
|
||||
body: { domain: 'hansbolag.example' },
|
||||
})
|
||||
const res = await route.handler(request, buildCtx(supabase))
|
||||
const { status, body } = await parseJsonResponse<{ data: typeof DOMAIN_ROW }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe('row-1')
|
||||
expect(claimMock).toHaveBeenCalledWith(expect.anything(), 'company-1', 'hansbolag.example')
|
||||
})
|
||||
|
||||
it('maps lib failures to their status codes', async () => {
|
||||
claimMock.mockResolvedValue({ ok: false, status: 409, error: 'Domänen är redan registrerad.' })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
const request = createMockRequest('/inbox/domain', {
|
||||
method: 'POST',
|
||||
body: { domain: 'hansbolag.example' },
|
||||
})
|
||||
const res = await route.handler(request, buildCtx(supabase))
|
||||
expect(res.status).toBe(409)
|
||||
})
|
||||
|
||||
it('rejects a missing domain field with 400', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
const request = createMockRequest('/inbox/domain', { method: 'POST', body: {} })
|
||||
const res = await route.handler(request, buildCtx(supabase))
|
||||
expect(res.status).toBe(400)
|
||||
expect(claimMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks sandbox companies from claiming a domain', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'owner' } }) // company_members role check
|
||||
enqueue({ data: { is_sandbox: true } }) // company_settings sandbox check
|
||||
const request = createMockRequest('/inbox/domain', {
|
||||
method: 'POST',
|
||||
body: { domain: 'hansbolag.example' },
|
||||
})
|
||||
const res = await route.handler(request, buildCtx(supabase))
|
||||
expect(res.status).toBe(403)
|
||||
expect(claimMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /inbox/domain/verify', () => {
|
||||
const route = findRoute('POST', '/inbox/domain/verify')
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns 403 for viewers', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'viewer' } })
|
||||
const res = await route.handler(
|
||||
createMockRequest('/inbox/domain/verify', { method: 'POST' }),
|
||||
buildCtx(supabase),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('re-checks verification for admins', async () => {
|
||||
verifyMock.mockResolvedValue({ ok: true, data: { ...DOMAIN_ROW, status: 'verified' } })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'admin' } })
|
||||
const res = await route.handler(
|
||||
createMockRequest('/inbox/domain/verify', { method: 'POST' }),
|
||||
buildCtx(supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.status).toBe('verified')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /inbox/domain', () => {
|
||||
const route = findRoute('DELETE', '/inbox/domain')
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('returns 403 for non-admin members', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'member' } })
|
||||
const res = await route.handler(
|
||||
createMockRequest('/inbox/domain', { method: 'DELETE' }),
|
||||
buildCtx(supabase),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
expect(removeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes the domain for owners', async () => {
|
||||
removeMock.mockResolvedValue({ ok: true, data: { removed: true } })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
const res = await route.handler(
|
||||
createMockRequest('/inbox/domain', { method: 'DELETE' }),
|
||||
buildCtx(supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ data: { removed: boolean } }>(res)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.removed).toBe(true)
|
||||
})
|
||||
|
||||
it('surfaces a Resend removal failure without deleting the row', async () => {
|
||||
removeMock.mockResolvedValue({ ok: false, status: 502, error: 'Kunde inte ta bort domänen.' })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { role: 'owner' } })
|
||||
const res = await route.handler(
|
||||
createMockRequest('/inbox/domain', { method: 'DELETE' }),
|
||||
buildCtx(supabase),
|
||||
)
|
||||
expect(res.status).toBe(502)
|
||||
})
|
||||
})
|
||||
|
||||
describe('INBOX_CUSTOM_DOMAINS_ENABLED gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
delete process.env.INBOX_CUSTOM_DOMAINS_ENABLED
|
||||
})
|
||||
|
||||
it('returns 403 FEATURE_DISABLED on every /inbox/domain route when the flag is off', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const routes: Array<[string, string]> = [
|
||||
['GET', '/inbox/domain'],
|
||||
['POST', '/inbox/domain'],
|
||||
['POST', '/inbox/domain/verify'],
|
||||
['DELETE', '/inbox/domain'],
|
||||
]
|
||||
for (const [method, path] of routes) {
|
||||
const res = await findRoute(method, path).handler(
|
||||
createMockRequest(path, { method }),
|
||||
buildCtx(supabase),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ code: string }>(res)
|
||||
expect(status).toBe(403)
|
||||
expect(body.code).toBe('FEATURE_DISABLED')
|
||||
}
|
||||
expect(getMock).not.toHaveBeenCalled()
|
||||
expect(claimMock).not.toHaveBeenCalled()
|
||||
expect(verifyMock).not.toHaveBeenCalled()
|
||||
expect(removeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,558 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
normalizeInboundDomain,
|
||||
validateClaimableDomain,
|
||||
mapResendDomainStatus,
|
||||
resolveClaimedDomainStatus,
|
||||
claimCustomDomain,
|
||||
checkCustomDomainVerification,
|
||||
removeCustomDomain,
|
||||
findCompanyForRecipientDomains,
|
||||
applyDomainStatusFromWebhook,
|
||||
} from '@/extensions/general/invoice-inbox/lib/custom-domains'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const { domainsMock } = vi.hoisted(() => ({
|
||||
domainsMock: {
|
||||
create: vi.fn(),
|
||||
get: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
list: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('resend', () => ({
|
||||
Resend: class {
|
||||
domains = domainsMock
|
||||
},
|
||||
}))
|
||||
|
||||
const RECEIVING_RECORD = {
|
||||
record: 'Receiving',
|
||||
name: 'hansbolag.example',
|
||||
value: 'inbound.resend.example',
|
||||
type: 'MX',
|
||||
ttl: 'Auto',
|
||||
status: 'not_started',
|
||||
priority: 10,
|
||||
}
|
||||
|
||||
describe('normalizeInboundDomain', () => {
|
||||
it('lowercases and strips trailing dots', () => {
|
||||
expect(normalizeInboundDomain('Faktura.HansBolag.SE.')).toBe('faktura.hansbolag.se')
|
||||
})
|
||||
|
||||
it('accepts a pasted URL', () => {
|
||||
expect(normalizeInboundDomain('https://hansbolag.se/kontakt?x=1')).toBe('hansbolag.se')
|
||||
})
|
||||
|
||||
it('accepts a pasted email address', () => {
|
||||
expect(normalizeInboundDomain('faktura@hansbolag.se')).toBe('hansbolag.se')
|
||||
})
|
||||
|
||||
it('punycodes Swedish IDN domains', () => {
|
||||
const result = normalizeInboundDomain('blåbär.se')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.startsWith('xn--')).toBe(true)
|
||||
expect(result!.endsWith('.se')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects hostnames without a dot', () => {
|
||||
expect(normalizeInboundDomain('nodots')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects empty input', () => {
|
||||
expect(normalizeInboundDomain('')).toBeNull()
|
||||
expect(normalizeInboundDomain(' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects IP addresses', () => {
|
||||
expect(normalizeInboundDomain('192.168.0.1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateClaimableDomain', () => {
|
||||
beforeEach(() => {
|
||||
process.env.RESEND_INBOUND_DOMAIN = 'arcim.example'
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.gnubok.example'
|
||||
})
|
||||
|
||||
it('blocks public mailbox providers', () => {
|
||||
expect(validateClaimableDomain('gmail.com')).not.toBeNull()
|
||||
expect(validateClaimableDomain('outlook.com')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('blocks the shared inbound domain and its subdomains', () => {
|
||||
expect(validateClaimableDomain('arcim.example')).not.toBeNull()
|
||||
expect(validateClaimableDomain('foo.arcim.example')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('blocks the app domain', () => {
|
||||
expect(validateClaimableDomain('app.gnubok.example')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('allows a normal customer domain', () => {
|
||||
expect(validateClaimableDomain('hansbolag.se')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapResendDomainStatus', () => {
|
||||
it('maps Resend statuses to our three buckets', () => {
|
||||
expect(mapResendDomainStatus('verified')).toBe('verified')
|
||||
// temporary_failure = previously verified, Resend still routing — keep routing.
|
||||
expect(mapResendDomainStatus('temporary_failure')).toBe('verified')
|
||||
expect(mapResendDomainStatus('failed')).toBe('failed')
|
||||
expect(mapResendDomainStatus('pending')).toBe('pending')
|
||||
expect(mapResendDomainStatus('not_started')).toBe('pending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveClaimedDomainStatus', () => {
|
||||
it('forces an adopted orphan to pending even when Resend reports verified', () => {
|
||||
// The security-critical case: adopting a domain freed by a deleted company
|
||||
// must NOT inherit a stale 'verified' — DNS control has to be re-proven.
|
||||
expect(resolveClaimedDomainStatus(true, 'verified')).toBe('pending')
|
||||
expect(resolveClaimedDomainStatus(true, 'temporary_failure')).toBe('pending')
|
||||
})
|
||||
|
||||
it('maps Resend status normally for a freshly created (non-adopted) domain', () => {
|
||||
expect(resolveClaimedDomainStatus(false, 'pending')).toBe('pending')
|
||||
expect(resolveClaimedDomainStatus(false, 'not_started')).toBe('pending')
|
||||
expect(resolveClaimedDomainStatus(false, 'verified')).toBe('verified')
|
||||
})
|
||||
})
|
||||
|
||||
describe('claimCustomDomain', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.RESEND_API_KEY = 'test-key'
|
||||
process.env.RESEND_INBOUND_DOMAIN = 'arcim.example'
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.gnubok.example'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
it('rejects an unparseable domain without touching the database', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'not a domain!')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(400)
|
||||
expect(domainsMock.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects blocked domains', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'gmail.com')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(400)
|
||||
})
|
||||
|
||||
it('registers the domain in Resend with receiving-only capabilities and stores the records', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', domain: 'hansbolag.example' } }) // insert
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'row-1',
|
||||
company_id: 'company-1',
|
||||
domain: 'hansbolag.example',
|
||||
status: 'pending',
|
||||
resend_domain_id: 'rd_1',
|
||||
dns_records: [RECEIVING_RECORD],
|
||||
},
|
||||
}) // update
|
||||
domainsMock.create.mockResolvedValue({ data: { id: 'rd_1' }, error: null })
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: { id: 'rd_1', status: 'pending', records: [RECEIVING_RECORD] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'HansBolag.example')
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.data.resend_domain_id).toBe('rd_1')
|
||||
expect(result.data.status).toBe('pending')
|
||||
}
|
||||
expect(domainsMock.create).toHaveBeenCalledWith({
|
||||
name: 'hansbolag.example',
|
||||
region: 'eu-west-1',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 409 when the company already has a domain', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: null,
|
||||
error: { code: '23505', message: 'duplicate key value violates unique constraint "idx_company_inbound_domains_company"' },
|
||||
})
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'hansbolag.example')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(409)
|
||||
expect(result.error).toContain('redan en egen domän')
|
||||
}
|
||||
expect(domainsMock.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 409 when another company owns the domain, without leaking who', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: null,
|
||||
error: { code: '23505', message: 'duplicate key value violates unique constraint "idx_company_inbound_domains_domain"' },
|
||||
})
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'hansbolag.example')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(409)
|
||||
expect(result.error).toBe('Domänen är redan registrerad.')
|
||||
}
|
||||
})
|
||||
|
||||
it('rolls back the row when Resend registration fails and no existing domain can be adopted', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', domain: 'hansbolag.example' } }) // insert
|
||||
enqueue({ data: null }) // rollback delete
|
||||
domainsMock.create.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'quota exceeded', statusCode: 422, name: 'validation_error' },
|
||||
})
|
||||
domainsMock.list.mockResolvedValue({ data: { data: [], has_more: false }, error: null })
|
||||
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'hansbolag.example')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(502)
|
||||
expect(result.error).toContain('quota exceeded')
|
||||
}
|
||||
})
|
||||
|
||||
it('adopts an orphaned receiving-only Resend domain when create says it already exists', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', domain: 'hansbolag.example' } }) // insert
|
||||
enqueue({
|
||||
data: { id: 'row-1', resend_domain_id: 'rd_9', status: 'pending', domain: 'hansbolag.example' },
|
||||
}) // update
|
||||
domainsMock.create.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'domain already exists', statusCode: 409, name: 'validation_error' },
|
||||
})
|
||||
domainsMock.list.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'rd_9',
|
||||
name: 'HansBolag.example',
|
||||
status: 'pending',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: { id: 'rd_9', status: 'pending', records: [RECEIVING_RECORD] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'hansbolag.example')
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.data.resend_domain_id).toBe('rd_9')
|
||||
})
|
||||
|
||||
it('refuses to adopt a sending domain — the platform outbound domain must never bind to a tenant', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', domain: 'hansbolag.example' } }) // insert
|
||||
enqueue({ data: null }) // rollback delete
|
||||
domainsMock.create.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'domain already exists', statusCode: 409, name: 'validation_error' },
|
||||
})
|
||||
domainsMock.list.mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'rd_prod_send',
|
||||
name: 'hansbolag.example',
|
||||
status: 'verified',
|
||||
capabilities: { receiving: 'disabled', sending: 'enabled' },
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await claimCustomDomain(supabase as unknown as SupabaseClient, 'company-1', 'hansbolag.example')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(409)
|
||||
expect(result.error).toContain('underdomän')
|
||||
}
|
||||
expect(domainsMock.get).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkCustomDomainVerification', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.RESEND_API_KEY = 'test-key'
|
||||
})
|
||||
|
||||
it('returns 404 when the company has no custom domain', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
const result = await checkCustomDomainVerification(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(404)
|
||||
})
|
||||
|
||||
it('persists the verified status from Resend', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { id: 'row-1', company_id: 'company-1', resend_domain_id: 'rd_1', verified_at: null },
|
||||
}) // select
|
||||
enqueue({
|
||||
data: { id: 'row-1', status: 'verified', domain: 'hansbolag.example' },
|
||||
}) // update
|
||||
domainsMock.verify.mockResolvedValue({ data: { object: 'domain', id: 'rd_1' }, error: null })
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'rd_1',
|
||||
status: 'verified',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
records: [{ ...RECEIVING_RECORD, status: 'verified' }],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await checkCustomDomainVerification(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.data.status).toBe('verified')
|
||||
expect(domainsMock.verify).toHaveBeenCalledWith('rd_1')
|
||||
})
|
||||
|
||||
it('never verifies a domain whose receiving capability is disabled', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: { id: 'row-1', company_id: 'company-1', resend_domain_id: 'rd_send', verified_at: null },
|
||||
}) // select — no update should follow
|
||||
domainsMock.verify.mockResolvedValue({ data: { object: 'domain', id: 'rd_send' }, error: null })
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'rd_send',
|
||||
status: 'verified', // verified for SENDING — must not count
|
||||
capabilities: { receiving: 'disabled', sending: 'enabled' },
|
||||
records: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await checkCustomDomainVerification(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(409)
|
||||
expect(result.error).toContain('mottagning')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeCustomDomain', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.RESEND_API_KEY = 'test-key'
|
||||
})
|
||||
|
||||
const RECEIVING_ONLY_DOMAIN = {
|
||||
id: 'rd_1',
|
||||
status: 'pending',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
records: [],
|
||||
}
|
||||
|
||||
it('removes the Resend domain and deletes the row', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', resend_domain_id: 'rd_1' } }) // select
|
||||
enqueue({ data: null }) // delete
|
||||
domainsMock.get.mockResolvedValue({ data: RECEIVING_ONLY_DOMAIN, error: null })
|
||||
domainsMock.remove.mockResolvedValue({ data: { id: 'rd_1', deleted: true }, error: null })
|
||||
|
||||
const result = await removeCustomDomain(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(true)
|
||||
expect(domainsMock.remove).toHaveBeenCalledWith('rd_1')
|
||||
})
|
||||
|
||||
it('tolerates a domain already gone from Resend', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', resend_domain_id: 'rd_1' } })
|
||||
enqueue({ data: null })
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'not found', statusCode: 404, name: 'not_found' },
|
||||
})
|
||||
|
||||
const result = await removeCustomDomain(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(true)
|
||||
expect(domainsMock.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the row when Resend removal fails — a verified orphan must never become adoptable', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', resend_domain_id: 'rd_1' } })
|
||||
domainsMock.get.mockResolvedValue({ data: RECEIVING_ONLY_DOMAIN, error: null })
|
||||
domainsMock.remove.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'internal error', statusCode: 500, name: 'application_error' },
|
||||
})
|
||||
|
||||
const result = await removeCustomDomain(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(502)
|
||||
})
|
||||
|
||||
it('deletes the row but never the Resend domain when it is not receiving-only', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// Legacy row bound to the platform's outbound sending domain.
|
||||
enqueue({ data: { id: 'row-1', company_id: 'company-1', resend_domain_id: 'rd_prod_send' } })
|
||||
enqueue({ data: null }) // row delete
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'rd_prod_send',
|
||||
status: 'verified',
|
||||
capabilities: { receiving: 'disabled', sending: 'enabled' },
|
||||
records: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const result = await removeCustomDomain(supabase as unknown as SupabaseClient, 'company-1')
|
||||
expect(result.ok).toBe(true)
|
||||
expect(domainsMock.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('findCompanyForRecipientDomains', () => {
|
||||
it('returns the first match in recipient order regardless of row order', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: [
|
||||
{ company_id: 'c2', domain: 'second.example' },
|
||||
{ company_id: 'c1', domain: 'first.example' },
|
||||
],
|
||||
})
|
||||
const match = await findCompanyForRecipientDomains(
|
||||
supabase as unknown as SupabaseClient,
|
||||
['first.example', 'second.example'],
|
||||
)
|
||||
expect(match).toEqual({ companyId: 'c1', domain: 'first.example' })
|
||||
})
|
||||
|
||||
it('returns null when nothing matches', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [] })
|
||||
const match = await findCompanyForRecipientDomains(
|
||||
supabase as unknown as SupabaseClient,
|
||||
['unknown.example'],
|
||||
)
|
||||
expect(match).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null without querying when there are no recipient domains', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const match = await findCompanyForRecipientDomains(supabase as unknown as SupabaseClient, [])
|
||||
expect(match).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyDomainStatusFromWebhook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.RESEND_API_KEY = 'test-key'
|
||||
})
|
||||
|
||||
it('updates the matching row and reports true when receiving is confirmed', async () => {
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'rd_1',
|
||||
status: 'verified',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
records: [RECEIVING_RECORD],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', verified_at: null } })
|
||||
enqueue({ data: null }) // update
|
||||
const matched = await applyDomainStatusFromWebhook(supabase as unknown as SupabaseClient, {
|
||||
id: 'rd_1',
|
||||
status: 'verified',
|
||||
records: [],
|
||||
})
|
||||
expect(matched).toBe(true)
|
||||
expect(domainsMock.get).toHaveBeenCalledWith('rd_1')
|
||||
})
|
||||
|
||||
it('never flips a sending-only domain to verified off the event status', async () => {
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'rd_1',
|
||||
status: 'verified',
|
||||
capabilities: { receiving: 'disabled', sending: 'enabled' },
|
||||
records: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', verified_at: null } })
|
||||
enqueue({ data: null }) // update (last_checked_at only)
|
||||
const matched = await applyDomainStatusFromWebhook(supabase as unknown as SupabaseClient, {
|
||||
id: 'rd_1',
|
||||
status: 'verified',
|
||||
records: [],
|
||||
})
|
||||
// Row matched, but the verified transition was refused.
|
||||
expect(matched).toBe(true)
|
||||
expect(domainsMock.get).toHaveBeenCalledWith('rd_1')
|
||||
})
|
||||
|
||||
it('keeps stored status when the capability lookup fails', async () => {
|
||||
domainsMock.get.mockResolvedValue({ data: null, error: { message: 'boom' } })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', verified_at: null } })
|
||||
enqueue({ data: null }) // update (last_checked_at only)
|
||||
const matched = await applyDomainStatusFromWebhook(supabase as unknown as SupabaseClient, {
|
||||
id: 'rd_1',
|
||||
status: 'verified',
|
||||
})
|
||||
expect(matched).toBe(true)
|
||||
})
|
||||
|
||||
it('skips the capability lookup for non-verified statuses', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', verified_at: null } })
|
||||
enqueue({ data: null }) // update
|
||||
const matched = await applyDomainStatusFromWebhook(supabase as unknown as SupabaseClient, {
|
||||
id: 'rd_1',
|
||||
status: 'failed',
|
||||
})
|
||||
expect(matched).toBe(true)
|
||||
expect(domainsMock.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports false for unknown Resend domain ids', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
const matched = await applyDomainStatusFromWebhook(supabase as unknown as SupabaseClient, {
|
||||
id: 'rd_unknown',
|
||||
status: 'verified',
|
||||
})
|
||||
expect(matched).toBe(false)
|
||||
expect(domainsMock.get).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,19 @@ vi.mock('@supabase/supabase-js', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
// applyDomainStatusFromWebhook confirms the receiving capability with Resend
|
||||
// before flipping a row to verified — keep that lookup off the network.
|
||||
const { domainsMock } = vi.hoisted(() => ({
|
||||
domainsMock: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('resend', () => ({
|
||||
Resend: class {
|
||||
domains = domainsMock
|
||||
},
|
||||
}))
|
||||
|
||||
// Rate limiter is a thin RPC wrapper; bypass it so the queued-mock sequence
|
||||
// in each test doesn't have to account for the extra Supabase call.
|
||||
vi.mock('@/lib/rate-limits/inbox', () => ({
|
||||
@@ -103,15 +116,142 @@ describe('POST /inbound', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns 404 when no recipient matches our domain', async () => {
|
||||
it('returns 404 when no recipient matches our domain or a verified custom domain', async () => {
|
||||
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
||||
mockReceivedEvent({ to: ['random@contoso.com'] }) as never
|
||||
)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [] }) // company_inbound_domains lookup finds nothing
|
||||
vi.mocked(createClient).mockReturnValue(supabase as never)
|
||||
|
||||
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
||||
const res = await webhookRoute.handler(request)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('routes mail on a verified custom domain to its company (any local part)', async () => {
|
||||
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
||||
mockReceivedEvent({ to: ['fakturor@hansbolag.example'], attachments: [] }) as never
|
||||
)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ company_id: 'company-9', domain: 'hansbolag.example' }] }) // verified domain
|
||||
enqueue({ data: { created_by: 'user-owner-9' } }) // company owner
|
||||
enqueue({ data: null }) // no-attachments error-row insert
|
||||
vi.mocked(createClient).mockReturnValue(supabase as never)
|
||||
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
||||
object: 'email',
|
||||
id: 'em_123',
|
||||
to: ['fakturor@hansbolag.example'],
|
||||
from: 'billing@supplier.com',
|
||||
created_at: '2026-04-20T10:00:00Z',
|
||||
subject: 'Invoice #5678',
|
||||
bcc: null,
|
||||
cc: null,
|
||||
reply_to: null,
|
||||
html: null,
|
||||
text: 'Body',
|
||||
headers: {},
|
||||
message_id: '<msg@x>',
|
||||
raw: null,
|
||||
attachments: [],
|
||||
} as never)
|
||||
|
||||
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
||||
const res = await webhookRoute.handler(request)
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.data.reason).toBe('no_attachments')
|
||||
})
|
||||
|
||||
it('does not route mail for an unverified custom domain', async () => {
|
||||
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
||||
mockReceivedEvent({ to: ['faktura@pending-bolag.example'] }) as never
|
||||
)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// status='verified' filter means a pending claim never matches
|
||||
enqueue({ data: [] })
|
||||
vi.mocked(createClient).mockReturnValue(supabase as never)
|
||||
|
||||
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
||||
const res = await webhookRoute.handler(request)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('prefers the shared-domain address when both shared and custom recipients are present', async () => {
|
||||
vi.mocked(verifyInboundWebhook).mockReturnValue(
|
||||
mockReceivedEvent({
|
||||
to: ['acme-ab-x7f2@arcim.io', 'faktura@hansbolag.example'],
|
||||
attachments: [],
|
||||
}) as never
|
||||
)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// Only the three shared-path queries are enqueued — if the handler also
|
||||
// ran the custom-domain lookup, the queue would shift and created_by
|
||||
// would resolve to null (500). A 200 proves the shared path won.
|
||||
enqueue({ data: { id: 'inbox-1', company_id: 'company-1', status: 'active' } })
|
||||
enqueue({ data: { created_by: 'user-owner-1' } })
|
||||
enqueue({ data: null }) // no-attachments error-row insert
|
||||
vi.mocked(createClient).mockReturnValue(supabase as never)
|
||||
vi.mocked(fetchReceivingEmail).mockResolvedValue({
|
||||
object: 'email',
|
||||
id: 'em_123',
|
||||
to: ['acme-ab-x7f2@arcim.io', 'faktura@hansbolag.example'],
|
||||
from: 'billing@supplier.com',
|
||||
created_at: '2026-04-20T10:00:00Z',
|
||||
subject: 'Invoice #5678',
|
||||
bcc: null,
|
||||
cc: null,
|
||||
reply_to: null,
|
||||
html: null,
|
||||
text: 'Body',
|
||||
headers: {},
|
||||
message_id: '<msg@x>',
|
||||
raw: null,
|
||||
attachments: [],
|
||||
} as never)
|
||||
|
||||
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
||||
const res = await webhookRoute.handler(request)
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.data.reason).toBe('no_attachments')
|
||||
})
|
||||
|
||||
it('applies domain.updated events to custom-domain rows', async () => {
|
||||
process.env.RESEND_API_KEY = 'test-key'
|
||||
domainsMock.get.mockResolvedValue({
|
||||
data: {
|
||||
id: 'rd_123',
|
||||
status: 'verified',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
records: [],
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(verifyInboundWebhook).mockReturnValue({
|
||||
type: 'domain.updated',
|
||||
created_at: '2026-07-01T10:00:00Z',
|
||||
data: {
|
||||
id: 'rd_123',
|
||||
name: 'hansbolag.example',
|
||||
status: 'verified',
|
||||
created_at: '2026-07-01T09:00:00Z',
|
||||
region: 'eu-west-1',
|
||||
records: [],
|
||||
},
|
||||
} as never)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'row-1', verified_at: null } }) // row by resend_domain_id
|
||||
enqueue({ data: null }) // update
|
||||
vi.mocked(createClient).mockReturnValue(supabase as never)
|
||||
|
||||
const request = createMockRequest('/inbound', { method: 'POST', body: {} })
|
||||
const res = await webhookRoute.handler(request)
|
||||
const body = await res.json()
|
||||
expect(res.status).toBe(200)
|
||||
expect(body.data.domain_updated).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 404 when the address is not in company_inboxes', async () => {
|
||||
vi.mocked(verifyInboundWebhook).mockReturnValue(mockReceivedEvent() as never)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { extractLocalPartForDomain } from '@/extensions/general/invoice-inbox/lib/resend-inbound'
|
||||
import { extractLocalPartForDomain, parseRecipients } from '@/extensions/general/invoice-inbox/lib/resend-inbound'
|
||||
|
||||
describe('extractLocalPartForDomain', () => {
|
||||
it('returns the local part when a recipient matches the domain', () => {
|
||||
@@ -50,3 +50,24 @@ describe('extractLocalPartForDomain', () => {
|
||||
expect(result).toBe('acme-xxx')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseRecipients', () => {
|
||||
it('splits recipients into lowercased localPart/domain pairs in order', () => {
|
||||
expect(
|
||||
parseRecipients(['Faktura@HansBolag.SE', 'billing@acme.se'])
|
||||
).toEqual([
|
||||
{ localPart: 'faktura', domain: 'hansbolag.se' },
|
||||
{ localPart: 'billing', domain: 'acme.se' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips malformed addresses', () => {
|
||||
expect(parseRecipients(['not-an-email', '@x.se', 'foo@', 'ok@a.se'])).toEqual([
|
||||
{ localPart: 'ok', domain: 'a.se' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty array for no recipients', () => {
|
||||
expect(parseRecipients([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
fetchReceivingEmail,
|
||||
fetchInboundAttachment,
|
||||
extractLocalPartForDomain,
|
||||
parseRecipients,
|
||||
isEmailReceivedEvent,
|
||||
ResendSignatureError,
|
||||
} from './lib/resend-inbound'
|
||||
@@ -17,6 +18,14 @@ import {
|
||||
getActiveInbox,
|
||||
composeInboxAddress,
|
||||
} from './lib/inbox-provisioning'
|
||||
import {
|
||||
claimCustomDomain,
|
||||
checkCustomDomainVerification,
|
||||
removeCustomDomain,
|
||||
getCustomDomain,
|
||||
findCompanyForRecipientDomains,
|
||||
applyDomainStatusFromWebhook,
|
||||
} from './lib/custom-domains'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { createSchedulesForSupplierInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { suggestBalanceAccount } from '@/lib/bookkeeping/accruals/account-suggestions'
|
||||
@@ -143,6 +152,27 @@ const UpdateExtractedDataSchema = z.object({
|
||||
.optional(),
|
||||
})
|
||||
|
||||
// Claim body for POST /inbox/domain. Length-capped only — real validation
|
||||
// (punycode, hostname shape, blocklist) lives in normalizeInboundDomain /
|
||||
// validateClaimableDomain so the same rules apply to every caller.
|
||||
const ClaimDomainSchema = z.object({
|
||||
domain: z.string().trim().min(1).max(255),
|
||||
})
|
||||
|
||||
// Custom inbound domains are fully built but deliberately not exposed —
|
||||
// product decision 2026-07-02: the default is the Fortnox-style shared
|
||||
// address (+ user-side forwarding); own-domain inbound waits for real demand.
|
||||
// Flip INBOX_CUSTOM_DOMAINS_ENABLED=true to re-enable the /inbox/domain
|
||||
// routes. The globe entry point in InvoiceInboxWorkspace was removed at the
|
||||
// same time — restore it when re-enabling.
|
||||
const customDomainsEnabled = () => process.env.INBOX_CUSTOM_DOMAINS_ENABLED === 'true'
|
||||
|
||||
const customDomainsDisabledResponse = () =>
|
||||
NextResponse.json(
|
||||
{ error: 'Egen domän är inte tillgänglig.', code: 'FEATURE_DISABLED' },
|
||||
{ status: 403 }
|
||||
)
|
||||
|
||||
const UPLOAD_ALLOWED_MIME_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
@@ -1288,6 +1318,123 @@ export const invoiceInboxExtension: Extension = {
|
||||
},
|
||||
},
|
||||
|
||||
// ── Custom inbound domain: read current state ────────────
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/inbox/domain',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!customDomainsEnabled()) return customDomainsDisabledResponse()
|
||||
|
||||
try {
|
||||
const row = await getCustomDomain(ctx.supabase, ctx.companyId)
|
||||
// null when the company has no custom domain — the UI renders the
|
||||
// claim form in that case.
|
||||
return NextResponse.json({ data: row })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to load domain' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Custom inbound domain: claim (admin/owner only) ──────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/inbox/domain',
|
||||
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!customDomainsEnabled()) return customDomainsDisabledResponse()
|
||||
|
||||
const isAdmin = await isCompanyAdmin(ctx.supabase, ctx.userId, ctx.companyId)
|
||||
if (!isAdmin) return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
|
||||
|
||||
// Sandbox companies are anonymous 24h demo accounts — letting them
|
||||
// register domains in our Resend account is a pure abuse vector.
|
||||
if (await isSandboxCompany(ctx.supabase, ctx.companyId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Egen domän är inte tillgänglig i sandlådan.' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Claiming hits the Resend domains API — share the per-company inbox
|
||||
// quota so a claim/delete loop can't burn the provider budget.
|
||||
const limit = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId)
|
||||
if (!limit.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'För många förfrågningar — försök igen om en stund.', retry_after: limit.retryAfterSec },
|
||||
{ status: 429, headers: { 'Retry-After': String(limit.retryAfterSec ?? 60) } },
|
||||
)
|
||||
}
|
||||
|
||||
let body: z.infer<typeof ClaimDomainSchema>
|
||||
try {
|
||||
body = ClaimDomainSchema.parse(await request.json())
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Invalid request body' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await claimCustomDomain(ctx.supabase, ctx.companyId, body.domain)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status })
|
||||
}
|
||||
return NextResponse.json({ data: result.data })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Custom inbound domain: re-check verification ─────────
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/inbox/domain/verify',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!customDomainsEnabled()) return customDomainsDisabledResponse()
|
||||
|
||||
const isAdmin = await isCompanyAdmin(ctx.supabase, ctx.userId, ctx.companyId)
|
||||
if (!isAdmin) return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
|
||||
|
||||
// verify() triggers a DNS check at Resend — rate-limit the button.
|
||||
const limit = await checkInboxUploadRateLimit(ctx.supabase, ctx.companyId)
|
||||
if (!limit.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: 'För många kontroller — försök igen om en stund.', retry_after: limit.retryAfterSec },
|
||||
{ status: 429, headers: { 'Retry-After': String(limit.retryAfterSec ?? 60) } },
|
||||
)
|
||||
}
|
||||
|
||||
const result = await checkCustomDomainVerification(ctx.supabase, ctx.companyId)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status })
|
||||
}
|
||||
return NextResponse.json({ data: result.data })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Custom inbound domain: remove (admin/owner only) ─────
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/inbox/domain',
|
||||
handler: async (_request: Request, ctx?: ExtensionContext) => {
|
||||
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!customDomainsEnabled()) return customDomainsDisabledResponse()
|
||||
|
||||
const isAdmin = await isCompanyAdmin(ctx.supabase, ctx.userId, ctx.companyId)
|
||||
if (!isAdmin) return NextResponse.json({ error: 'Behörighet saknas.' }, { status: 403 })
|
||||
|
||||
const result = await removeCustomDomain(ctx.supabase, ctx.companyId)
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ error: result.error }, { status: result.status })
|
||||
}
|
||||
return NextResponse.json({ data: result.data })
|
||||
},
|
||||
},
|
||||
|
||||
// ── Resend Inbound webhook (Svix-signed, no user auth) ──
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1313,44 +1460,87 @@ export const invoiceInboxExtension: Extension = {
|
||||
return NextResponse.json({ error: 'Verification failed' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Resend pushes domain.* lifecycle events to the same webhook. Apply
|
||||
// domain.updated to custom-domain rows so verification flips without
|
||||
// the user pressing "Kontrollera igen" (requires the event type to be
|
||||
// subscribed on the Resend webhook; harmless when it isn't).
|
||||
if (event.type === 'domain.updated') {
|
||||
const domainServiceSupabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
const matched = await applyDomainStatusFromWebhook(domainServiceSupabase, {
|
||||
id: event.data.id,
|
||||
status: event.data.status,
|
||||
records: event.data.records,
|
||||
})
|
||||
return NextResponse.json({ data: { domain_updated: matched } })
|
||||
}
|
||||
|
||||
if (!isEmailReceivedEvent(event)) {
|
||||
return NextResponse.json({ data: { ignored: event.type } }, { status: 200 })
|
||||
}
|
||||
|
||||
const { email_id, to, from, subject, message_id, created_at } = event.data
|
||||
|
||||
const localPart = extractLocalPartForDomain(to, domain)
|
||||
if (!localPart) {
|
||||
console.warn('[invoice-inbox/inbound] No recipient matched domain', { to, domain })
|
||||
return NextResponse.json({ error: 'No matching recipient' }, { status: 404 })
|
||||
}
|
||||
|
||||
const serviceSupabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
|
||||
const { data: inbox } = await serviceSupabase
|
||||
.from('company_inboxes')
|
||||
.select('id, company_id, status')
|
||||
.eq('local_part', localPart)
|
||||
.maybeSingle()
|
||||
// Recipient → company resolution. Shared-domain addresses first
|
||||
// (existing local_part flow), then per-company verified custom
|
||||
// domains. Custom domains are catch-all by design: MX routing is
|
||||
// per-domain, and a supplier typing fakturor@ instead of faktura@
|
||||
// must land in the inbox rather than silently vanish (Resend has
|
||||
// already accepted the message; there is no bounce path).
|
||||
let companyId: string | null = null
|
||||
let sharedInboxStatus: string | null = null
|
||||
|
||||
if (!inbox) {
|
||||
return NextResponse.json({ error: 'Address not found' }, { status: 404 })
|
||||
const localPart = extractLocalPartForDomain(to, domain)
|
||||
if (localPart) {
|
||||
const { data: inbox } = await serviceSupabase
|
||||
.from('company_inboxes')
|
||||
.select('id, company_id, status')
|
||||
.eq('local_part', localPart)
|
||||
.maybeSingle()
|
||||
if (inbox) {
|
||||
sharedInboxStatus = inbox.status
|
||||
if (inbox.status === 'active') companyId = inbox.company_id
|
||||
}
|
||||
}
|
||||
if (inbox.status !== 'active') {
|
||||
return NextResponse.json({ error: 'Address no longer active' }, { status: 410 })
|
||||
|
||||
if (!companyId) {
|
||||
const customDomains = parseRecipients(to)
|
||||
.map((r) => r.domain)
|
||||
.filter((d) => d !== domain.toLowerCase())
|
||||
if (customDomains.length > 0) {
|
||||
const match = await findCompanyForRecipientDomains(serviceSupabase, customDomains)
|
||||
if (match) companyId = match.companyId
|
||||
}
|
||||
}
|
||||
|
||||
if (!companyId) {
|
||||
// Preserve the pre-custom-domain status semantics: 410 for a
|
||||
// deprecated/blocked shared address, 404 otherwise.
|
||||
if (sharedInboxStatus && sharedInboxStatus !== 'active') {
|
||||
return NextResponse.json({ error: 'Address no longer active' }, { status: 410 })
|
||||
}
|
||||
console.warn('[invoice-inbox/inbound] No recipient matched', { to, domain })
|
||||
return NextResponse.json(
|
||||
{ error: localPart ? 'Address not found' : 'No matching recipient' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: company } = await serviceSupabase
|
||||
.from('companies')
|
||||
.select('created_by')
|
||||
.eq('id', inbox.company_id)
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
|
||||
if (!company?.created_by) {
|
||||
console.error('[invoice-inbox/inbound] Company has no created_by', inbox.company_id)
|
||||
console.error('[invoice-inbox/inbound] Company has no created_by', companyId)
|
||||
return NextResponse.json({ error: 'Company owner missing' }, { status: 500 })
|
||||
}
|
||||
const userId = company.created_by
|
||||
@@ -1370,11 +1560,11 @@ export const invoiceInboxExtension: Extension = {
|
||||
// Per-company rate limit (30/min, 500/day). Same Postgres-backed
|
||||
// RPC as /upload. Acknowledge + drop on cap — returning 429 to
|
||||
// Resend would just consume more budget via their retry.
|
||||
const limit = await checkInboxUploadRateLimit(serviceSupabase, inbox.company_id)
|
||||
const limit = await checkInboxUploadRateLimit(serviceSupabase, companyId)
|
||||
if (!limit.ok) {
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId: inbox.company_id,
|
||||
companyId,
|
||||
correlationId: email_id,
|
||||
aggregateType: 'System',
|
||||
aggregateId: email_id,
|
||||
@@ -1404,7 +1594,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
if (truncatedCount > 0) {
|
||||
try {
|
||||
await appendProcessingHistory({
|
||||
companyId: inbox.company_id,
|
||||
companyId,
|
||||
correlationId: email_id,
|
||||
aggregateType: 'System',
|
||||
aggregateId: email_id,
|
||||
@@ -1426,7 +1616,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
|
||||
if (attachments.length === 0) {
|
||||
await serviceSupabase.from('invoice_inbox_items').insert({
|
||||
company_id: inbox.company_id,
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: 'error',
|
||||
source: 'email',
|
||||
@@ -1459,7 +1649,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
// oversized values when read back into the UI / audit trails.
|
||||
try {
|
||||
await serviceSupabase.from('invoice_inbox_items').insert({
|
||||
company_id: inbox.company_id,
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: 'error',
|
||||
source: 'email',
|
||||
@@ -1530,7 +1720,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
const innerResult = await uploadAndExtract(
|
||||
serviceSupabase,
|
||||
userId,
|
||||
inbox.company_id,
|
||||
companyId,
|
||||
{ name: innerName, buffer: innerArrayBuffer, type: innerType },
|
||||
'email',
|
||||
{
|
||||
@@ -1562,7 +1752,7 @@ export const invoiceInboxExtension: Extension = {
|
||||
const result = await uploadAndExtract(
|
||||
serviceSupabase,
|
||||
userId,
|
||||
inbox.company_id,
|
||||
companyId,
|
||||
{ name: download.filename, buffer: download.buffer, type: download.contentType },
|
||||
'email',
|
||||
{
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
import { Resend } from 'resend'
|
||||
import type { Domain, DomainStatus } from 'resend'
|
||||
import { domainToASCII } from 'node:url'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CompanyInboundDomain, CompanyInboundDomainStatus } from '@/types'
|
||||
|
||||
function getResend(): Resend {
|
||||
const apiKey = process.env.RESEND_API_KEY
|
||||
if (!apiKey) throw new Error('RESEND_API_KEY is required')
|
||||
return new Resend(apiKey)
|
||||
}
|
||||
|
||||
export type CustomDomainResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; status: number; error: string }
|
||||
|
||||
// Public mailbox providers a company can never own. DNS verification is the
|
||||
// real ownership gate — this list only exists to fail fast with a clear
|
||||
// message instead of a claim that can never verify.
|
||||
const PUBLIC_EMAIL_DOMAINS = new Set([
|
||||
'gmail.com',
|
||||
'googlemail.com',
|
||||
'outlook.com',
|
||||
'hotmail.com',
|
||||
'hotmail.se',
|
||||
'live.com',
|
||||
'live.se',
|
||||
'msn.com',
|
||||
'icloud.com',
|
||||
'me.com',
|
||||
'mac.com',
|
||||
'yahoo.com',
|
||||
'ymail.com',
|
||||
'protonmail.com',
|
||||
'proton.me',
|
||||
'fastmail.com',
|
||||
'gmx.com',
|
||||
'telia.com',
|
||||
'comhem.se',
|
||||
'spray.se',
|
||||
'passagen.se',
|
||||
])
|
||||
|
||||
// Accepts what users actually paste — "Faktura.Hansbolag.SE.", a full URL, or
|
||||
// an email address — and reduces it to a lowercased, punycoded hostname.
|
||||
// Returns null when no valid hostname can be extracted.
|
||||
export function normalizeInboundDomain(raw: string): string | null {
|
||||
let value = String(raw ?? '').trim().toLowerCase()
|
||||
value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//, '') // strip scheme
|
||||
value = value.split('/')[0].split('?')[0]
|
||||
const atIndex = value.lastIndexOf('@')
|
||||
if (atIndex !== -1) value = value.slice(atIndex + 1)
|
||||
value = value.replace(/^\.+|\.+$/g, '')
|
||||
if (!value) return null
|
||||
|
||||
// IDN → punycode (blåbär.se → xn--blbr-noab.se). Returns '' when the input
|
||||
// is not a valid domain.
|
||||
const ascii = domainToASCII(value)
|
||||
if (!ascii) return null
|
||||
|
||||
return isValidHostname(ascii) ? ascii : null
|
||||
}
|
||||
|
||||
function isValidHostname(domain: string): boolean {
|
||||
if (domain.length < 4 || domain.length > 253) return false
|
||||
const labels = domain.split('.')
|
||||
if (labels.length < 2) return false
|
||||
if (!labels.every((l) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) return false
|
||||
// TLD must contain a letter — rejects IP addresses and all-numeric TLDs.
|
||||
return /[a-z]/.test(labels[labels.length - 1])
|
||||
}
|
||||
|
||||
// Returns a Swedish error message when the domain must not be claimed, or
|
||||
// null when it is claimable. Blocks public mailbox providers, the shared
|
||||
// inbound domain (and its subdomains), and the app's own domain.
|
||||
export function validateClaimableDomain(domain: string): string | null {
|
||||
if (PUBLIC_EMAIL_DOMAINS.has(domain)) {
|
||||
return 'Publika e-postdomäner (t.ex. Gmail, Outlook) kan inte användas. Ange en domän som bolaget äger.'
|
||||
}
|
||||
|
||||
const reserved: string[] = []
|
||||
const shared = process.env.RESEND_INBOUND_DOMAIN?.toLowerCase()
|
||||
if (shared) reserved.push(shared)
|
||||
try {
|
||||
const appHost = new URL(process.env.NEXT_PUBLIC_APP_URL ?? '').hostname.toLowerCase()
|
||||
if (appHost) reserved.push(appHost)
|
||||
} catch {
|
||||
// No parseable app URL — nothing extra to reserve.
|
||||
}
|
||||
|
||||
for (const r of reserved) {
|
||||
if (domain === r || domain.endsWith(`.${r}`)) {
|
||||
return 'Den här domänen är reserverad och kan inte användas som egen domän.'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// `temporary_failure` is a runtime status the Resend API can still return but
|
||||
// which the SDK's DomainStatus type dropped in 6.16.0 — accept it explicitly.
|
||||
export function mapResendDomainStatus(
|
||||
status: DomainStatus | 'temporary_failure',
|
||||
): CompanyInboundDomainStatus {
|
||||
switch (status) {
|
||||
case 'verified':
|
||||
return 'verified'
|
||||
// temporary_failure = a previously verified domain failed a DNS re-check;
|
||||
// Resend keeps the domain active while it retries (~72h). Keep routing
|
||||
// rather than silently dropping the customer's invoices on a DNS blip.
|
||||
case 'temporary_failure':
|
||||
return 'verified'
|
||||
case 'failed':
|
||||
return 'failed'
|
||||
default:
|
||||
return 'pending' // 'pending' | 'not_started'
|
||||
}
|
||||
}
|
||||
|
||||
// Status to persist when a claim first registers OR adopts a domain in Resend.
|
||||
// A freshly-created domain has no DNS yet, so it is always unverified. An
|
||||
// ADOPTED orphan (left by a crashed earlier claim, or freed by a deleted
|
||||
// company whose Resend domain was never cleaned up) may still report 'verified'
|
||||
// from a previous owner — NEVER inherit that. Force the new claimant to re-prove
|
||||
// DNS control via checkCustomDomainVerification() before any mail routes;
|
||||
// otherwise a different tenant could re-claim a freed domain and silently
|
||||
// inherit routing for mail the original owner's MX still delegates to Resend.
|
||||
// Kept pure so the security rule is unit-testable.
|
||||
export function resolveClaimedDomainStatus(
|
||||
wasAdopted: boolean,
|
||||
resendStatus: DomainStatus | 'temporary_failure',
|
||||
): CompanyInboundDomainStatus {
|
||||
return wasAdopted ? 'pending' : mapResendDomainStatus(resendStatus)
|
||||
}
|
||||
|
||||
// Only domains this feature created (receiving-only) may ever be adopted,
|
||||
// verified, or deleted from Resend here. The same Resend account also holds
|
||||
// the platform's OUTBOUND domains (e.g. the invoiceservice@ sender): adopting
|
||||
// one binds a tenant row to production sending infrastructure, and removing
|
||||
// one would kill outbound mail for every customer.
|
||||
export function isReceivingOnlyProfile(
|
||||
capabilities: { sending?: string; receiving?: string } | null | undefined
|
||||
): boolean {
|
||||
return capabilities?.receiving === 'enabled' && capabilities?.sending === 'disabled'
|
||||
}
|
||||
|
||||
export async function getCustomDomain(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
): Promise<CompanyInboundDomain | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw new Error(`Failed to load custom domain: ${error.message}`)
|
||||
return (data as CompanyInboundDomain | null) ?? null
|
||||
}
|
||||
|
||||
// A claim that crashed between the Resend create and the row update leaves an
|
||||
// orphan domain in our Resend account. On the next claim attempt the create
|
||||
// fails ("already exists") — adopt the existing Resend domain instead of
|
||||
// dead-ending the user. Safe because removeCustomDomain() never deletes the
|
||||
// DB row while the domain still exists in Resend, so an adoptable domain can
|
||||
// never belong to another live company_inbound_domains row.
|
||||
async function findExistingResendDomain(resend: Resend, domain: string): Promise<Domain | null> {
|
||||
let after: string | undefined
|
||||
for (let page = 0; page < 10; page++) {
|
||||
const { data, error } = await resend.domains.list(
|
||||
after ? { limit: 100, after } : { limit: 100 }
|
||||
)
|
||||
if (error || !data) return null
|
||||
// `domain` is already normalized (lowercased, punycoded) — run Resend's
|
||||
// name through the same normalization so an IDN stored in unicode form
|
||||
// still matches.
|
||||
const hit = data.data.find((d) => (normalizeInboundDomain(d.name) ?? d.name.toLowerCase()) === domain)
|
||||
if (hit) return hit
|
||||
if (!data.has_more || data.data.length === 0) return null
|
||||
after = data.data[data.data.length - 1].id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Claim a custom inbound domain for the company: insert the row, register the
|
||||
// domain in our Resend account with the receiving capability, and store the
|
||||
// DNS records the user must publish. The DB insert goes first so the unique
|
||||
// indexes ((lower(domain)) and (company_id)) serialize concurrent claims
|
||||
// before we ever talk to Resend.
|
||||
export async function claimCustomDomain(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
rawDomain: string
|
||||
): Promise<CustomDomainResult<CompanyInboundDomain>> {
|
||||
const domain = normalizeInboundDomain(rawDomain)
|
||||
if (!domain) {
|
||||
return { ok: false, status: 400, error: 'Ogiltig domän. Ange t.ex. faktura.dittbolag.se.' }
|
||||
}
|
||||
const blocked = validateClaimableDomain(domain)
|
||||
if (blocked) return { ok: false, status: 400, error: blocked }
|
||||
|
||||
const { data: inserted, error: insertError } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.insert({ company_id: companyId, domain, status: 'pending' })
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (insertError || !inserted) {
|
||||
if (insertError?.code === '23505') {
|
||||
const message = insertError.message.includes('idx_company_inbound_domains_company')
|
||||
? 'Bolaget har redan en egen domän. Ta bort den innan du lägger till en ny.'
|
||||
: 'Domänen är redan registrerad.'
|
||||
return { ok: false, status: 409, error: message }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
error: insertError?.message ?? 'Kunde inte spara domänen.',
|
||||
}
|
||||
}
|
||||
|
||||
const rollback = async () => {
|
||||
await supabase
|
||||
.from('company_inbound_domains')
|
||||
.delete()
|
||||
.eq('id', inserted.id)
|
||||
.eq('company_id', companyId)
|
||||
}
|
||||
|
||||
try {
|
||||
const resend = getResend()
|
||||
|
||||
// Receiving only — we never send from the customer's domain, and skipping
|
||||
// the sending capability keeps the DNS record list minimal.
|
||||
let resendDomainId: string
|
||||
let wasAdopted = false
|
||||
const created = await resend.domains.create({
|
||||
name: domain,
|
||||
region: 'eu-west-1',
|
||||
capabilities: { receiving: 'enabled', sending: 'disabled' },
|
||||
})
|
||||
|
||||
if (created.error || !created.data) {
|
||||
const adopted = await findExistingResendDomain(resend, domain)
|
||||
if (!adopted) {
|
||||
await rollback()
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: `Kunde inte registrera domänen hos e-postleverantören: ${created.error?.message ?? 'okänt fel'}`,
|
||||
}
|
||||
}
|
||||
// Never adopt a domain this feature didn't create — e.g. the platform's
|
||||
// own outbound (sending) domains, which live in the same Resend account.
|
||||
if (!isReceivingOnlyProfile(adopted.capabilities)) {
|
||||
await rollback()
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
error:
|
||||
'Domänen används redan för e-postutskick i plattformen och kan inte användas som inkorgsdomän. Använd en underdomän i stället.',
|
||||
}
|
||||
}
|
||||
resendDomainId = adopted.id
|
||||
wasAdopted = true
|
||||
} else {
|
||||
resendDomainId = created.data.id
|
||||
}
|
||||
|
||||
// get() rather than the create response: on the adoption path we have no
|
||||
// records yet, and get() returns the same shape either way.
|
||||
const fetched = await resend.domains.get(resendDomainId)
|
||||
if (fetched.error || !fetched.data) {
|
||||
await rollback()
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: `Kunde inte hämta DNS-poster: ${fetched.error?.message ?? 'okänt fel'}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Adopted orphans never inherit 'verified' — see resolveClaimedDomainStatus.
|
||||
const status = resolveClaimedDomainStatus(wasAdopted, fetched.data.status)
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.update({
|
||||
resend_domain_id: resendDomainId,
|
||||
dns_records: fetched.data.records,
|
||||
status,
|
||||
verified_at: status === 'verified' ? new Date().toISOString() : null,
|
||||
last_checked_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', inserted.id)
|
||||
.eq('company_id', companyId)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (updateError || !updated) {
|
||||
await rollback()
|
||||
return { ok: false, status: 500, error: updateError?.message ?? 'Kunde inte spara DNS-poster.' }
|
||||
}
|
||||
|
||||
return { ok: true, data: updated as CompanyInboundDomain }
|
||||
} catch (err) {
|
||||
await rollback()
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: err instanceof Error ? err.message : 'Domänregistreringen misslyckades.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-check verification with Resend and persist the outcome. verify() kicks
|
||||
// off Resend's DNS check; get() reads the (possibly updated) status and the
|
||||
// per-record state shown in the UI.
|
||||
export async function checkCustomDomainVerification(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
): Promise<CustomDomainResult<CompanyInboundDomain>> {
|
||||
const row = await getCustomDomain(supabase, companyId)
|
||||
if (!row) return { ok: false, status: 404, error: 'Ingen egen domän är registrerad.' }
|
||||
if (!row.resend_domain_id) {
|
||||
return { ok: false, status: 409, error: 'Domänen saknar koppling till e-postleverantören. Ta bort den och lägg till den igen.' }
|
||||
}
|
||||
|
||||
try {
|
||||
const resend = getResend()
|
||||
await resend.domains.verify(row.resend_domain_id)
|
||||
const fetched = await resend.domains.get(row.resend_domain_id)
|
||||
if (fetched.error || !fetched.data) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: `Kunde inte kontrollera domänen: ${fetched.error?.message ?? 'okänt fel'}`,
|
||||
}
|
||||
}
|
||||
|
||||
// A domain without the receiving capability can never take inbound mail —
|
||||
// Resend's 'verified' there only reflects sending records. Fail loudly
|
||||
// instead of ever flipping such a row to verified (guards legacy rows
|
||||
// bound to a sending domain before the adopt-profile check existed).
|
||||
if (fetched.data.capabilities?.receiving !== 'enabled') {
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
error:
|
||||
'Domänen är inte konfigurerad för mottagning hos e-postleverantören. Ta bort den och använd en underdomän i stället.',
|
||||
}
|
||||
}
|
||||
|
||||
const status = mapResendDomainStatus(fetched.data.status)
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.update({
|
||||
status,
|
||||
dns_records: fetched.data.records,
|
||||
last_checked_at: new Date().toISOString(),
|
||||
verified_at: status === 'verified' ? (row.verified_at ?? new Date().toISOString()) : row.verified_at,
|
||||
})
|
||||
.eq('id', row.id)
|
||||
.eq('company_id', companyId)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (updateError || !updated) {
|
||||
return { ok: false, status: 500, error: updateError?.message ?? 'Kunde inte spara status.' }
|
||||
}
|
||||
return { ok: true, data: updated as CompanyInboundDomain }
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: err instanceof Error ? err.message : 'Kontrollen misslyckades.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the custom domain: delete it from Resend first, then the row.
|
||||
// Order matters — the row is only deleted once the domain is confirmed gone
|
||||
// from Resend (or was never there). Deleting the row while a verified domain
|
||||
// lingers in our Resend account would let a later claim adopt a domain whose
|
||||
// MX still receives someone else's mail.
|
||||
export async function removeCustomDomain(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string
|
||||
): Promise<CustomDomainResult<{ removed: true }>> {
|
||||
const row = await getCustomDomain(supabase, companyId)
|
||||
if (!row) return { ok: false, status: 404, error: 'Ingen egen domän är registrerad.' }
|
||||
|
||||
if (row.resend_domain_id) {
|
||||
try {
|
||||
const resend = getResend()
|
||||
// Only delete Resend domains this feature created (receiving-only). A
|
||||
// row bound to anything else (pre-guard legacy, e.g. an outbound
|
||||
// sending domain) must never take production infrastructure down with
|
||||
// it — skip the Resend removal and just drop the row. Safe, because
|
||||
// the adopt path refuses non-receiving-only domains, so the leftover
|
||||
// Resend domain is not adoptable by another company.
|
||||
const fetched = await resend.domains.get(row.resend_domain_id)
|
||||
if (fetched.error && fetched.error.statusCode !== 404) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: `Kunde inte kontrollera domänen hos e-postleverantören: ${fetched.error.message}`,
|
||||
}
|
||||
}
|
||||
if (fetched.data && isReceivingOnlyProfile(fetched.data.capabilities)) {
|
||||
const removed = await resend.domains.remove(row.resend_domain_id)
|
||||
if (removed.error && removed.error.statusCode !== 404) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: `Kunde inte ta bort domänen hos e-postleverantören: ${removed.error.message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: err instanceof Error ? err.message : 'Borttagningen misslyckades.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { error: deleteError } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.delete()
|
||||
.eq('id', row.id)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (deleteError) return { ok: false, status: 500, error: deleteError.message }
|
||||
return { ok: true, data: { removed: true } }
|
||||
}
|
||||
|
||||
// Webhook-side lookup: given the recipient domains of an inbound email,
|
||||
// return the owning company for the first verified match (recipient order
|
||||
// preserved). Catch-all by design — any local part on a verified domain
|
||||
// routes to the company, so a supplier typing fakturor@ instead of faktura@
|
||||
// still lands instead of silently vanishing (Resend has already accepted the
|
||||
// message at SMTP; there is no bounce path).
|
||||
export async function findCompanyForRecipientDomains(
|
||||
supabase: SupabaseClient,
|
||||
recipientDomains: string[]
|
||||
): Promise<{ companyId: string; domain: string } | null> {
|
||||
const unique = [...new Set(recipientDomains.map((d) => d.toLowerCase()))]
|
||||
if (unique.length === 0) return null
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.select('company_id, domain')
|
||||
.in('domain', unique)
|
||||
.eq('status', 'verified')
|
||||
|
||||
if (error || !data || data.length === 0) return null
|
||||
|
||||
const byDomain = new Map(
|
||||
(data as Array<{ company_id: string; domain: string }>).map((r) => [r.domain.toLowerCase(), r])
|
||||
)
|
||||
for (const d of unique) {
|
||||
const hit = byDomain.get(d)
|
||||
if (hit) return { companyId: hit.company_id, domain: hit.domain }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Applies a Resend `domain.updated` webhook event so verification flips
|
||||
// without the user pressing "Kontrollera igen". No-op when the domain id is
|
||||
// unknown (e.g. the account's sending domains). Returns whether a row matched.
|
||||
export async function applyDomainStatusFromWebhook(
|
||||
supabase: SupabaseClient,
|
||||
event: { id: string; status: string; records?: unknown }
|
||||
): Promise<boolean> {
|
||||
const { data: row } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.select('id, verified_at')
|
||||
.eq('resend_domain_id', event.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (!row) return false
|
||||
|
||||
const status = mapResendDomainStatus(event.status as DomainStatus)
|
||||
|
||||
// The event's 'verified' is the domain's mixed sending/receiving status —
|
||||
// it carries no capability breakdown, so it can reflect sending-only
|
||||
// records. Mirror checkCustomDomainVerification: confirm the receiving
|
||||
// capability with Resend before ever flipping a row to verified. On a
|
||||
// failed lookup or a sending-only profile, keep the stored status (the
|
||||
// manual "Kontrollera igen" path remains available) — fail closed, never
|
||||
// route inbound mail off an unproven capability.
|
||||
if (status === 'verified') {
|
||||
let receivingConfirmed = false
|
||||
try {
|
||||
const fetched = await getResend().domains.get(event.id)
|
||||
receivingConfirmed =
|
||||
!fetched.error && fetched.data?.capabilities?.receiving === 'enabled'
|
||||
} catch {
|
||||
receivingConfirmed = false
|
||||
}
|
||||
if (!receivingConfirmed) {
|
||||
const { error } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.update({
|
||||
...(event.records !== undefined ? { dns_records: event.records } : {}),
|
||||
last_checked_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', (row as { id: string }).id)
|
||||
return !error
|
||||
}
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from('company_inbound_domains')
|
||||
.update({
|
||||
status,
|
||||
...(event.records !== undefined ? { dns_records: event.records } : {}),
|
||||
last_checked_at: new Date().toISOString(),
|
||||
verified_at:
|
||||
status === 'verified'
|
||||
? ((row as { verified_at: string | null }).verified_at ?? new Date().toISOString())
|
||||
: (row as { verified_at: string | null }).verified_at,
|
||||
})
|
||||
.eq('id', (row as { id: string }).id)
|
||||
|
||||
return !error
|
||||
}
|
||||
@@ -94,6 +94,19 @@ export function extractLocalPartForDomain(recipients: string[], domain: string):
|
||||
return null
|
||||
}
|
||||
|
||||
// Splits every parseable recipient into { localPart, domain }, lowercased and
|
||||
// in original order. Used to match recipients against per-company verified
|
||||
// custom domains when none of them is on the shared inbound domain.
|
||||
export function parseRecipients(recipients: string[]): Array<{ localPart: string; domain: string }> {
|
||||
const parsed: Array<{ localPart: string; domain: string }> = []
|
||||
for (const addr of recipients) {
|
||||
const match = addr.match(/^\s*([^@\s]+)@([^@\s]+?)\s*$/)
|
||||
if (!match) continue
|
||||
parsed.push({ localPart: match[1].toLowerCase(), domain: match[2].toLowerCase() })
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function isEmailReceivedEvent(event: WebhookEventPayload): event is EmailReceivedEvent {
|
||||
return event.type === 'email.received'
|
||||
}
|
||||
|
||||
@@ -85,9 +85,16 @@ describe('tools/list payload size guard', () => {
|
||||
// already use the compact "Dims bag" form (~90 tokens trimmed first);
|
||||
// the remainder is schema structure the resolve-don't-select contract
|
||||
// depends on, not trimmable prose.
|
||||
// * 44K → 45K when the rot/rut branch merged with main: main's #877 put
|
||||
// qualified identifiers in all tool output schemas (+~260 across 103
|
||||
// tools — wire contract, not prose) and the branch added
|
||||
// gnubok_generate_rot_rut_file (~444: begäran-om-utbetalning file flow,
|
||||
// eligible/blocked per-invoice output). Each side alone was under the
|
||||
// ceiling; the combination crossed it by ~220. Descriptions are at
|
||||
// their trimmed floor per the entries above.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools — if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(44_000)
|
||||
expect(approxTokens).toBeLessThan(45_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Tests for the test-mode API-key write guard in the MCP dispatcher.
|
||||
*
|
||||
* A `gnubok_sk_test_` key is bound to the REAL active company. On the v1 REST
|
||||
* surface every write is forced to dry-run (or blocked when it can't be
|
||||
* simulated). The MCP tools/call path must mirror that: a write tool that
|
||||
* cannot be simulated is refused BEFORE execute() so a test key can never stage
|
||||
* a real pending_operation (and, with the approve scope, commit it). Read tools
|
||||
* pass through unchanged.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
createServiceClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/auth/api-keys')>()
|
||||
const chain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
|
||||
}
|
||||
return () => chain
|
||||
},
|
||||
},
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
extractBearerToken: vi.fn().mockReturnValue('test-token'),
|
||||
// A TEST-mode key holding the approve scope, so the scope gate passes and
|
||||
// the test-key write guard is what we exercise.
|
||||
validateApiKey: vi.fn().mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: '11111111-1111-4111-8111-111111111111',
|
||||
scopes: ['pending_operations:approve', 'reports:read'],
|
||||
apiKeyId: 'key-test-1',
|
||||
apiKeyName: 'Test Key',
|
||||
mode: 'test',
|
||||
}),
|
||||
createServiceClientNoCookies: vi.fn(() => ({ from: () => chain, rpc: () => chain })),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
||||
return { ...actual, hasCapability: vi.fn().mockResolvedValue(true) }
|
||||
})
|
||||
|
||||
import { handleMcpRequest } from '../server'
|
||||
|
||||
function mcpToolCall(name: string, args: Record<string, unknown> = {}): Request {
|
||||
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }),
|
||||
})
|
||||
}
|
||||
|
||||
interface ToolCalledEvent {
|
||||
tool: string
|
||||
success: boolean
|
||||
isError: boolean
|
||||
errorKind: string | null
|
||||
latencyMs: number
|
||||
}
|
||||
|
||||
function captureNextToolCalled(): Promise<ToolCalledEvent> {
|
||||
return new Promise((resolve) => {
|
||||
const off = eventBus.on('mcp.tool_called', (payload) => {
|
||||
off()
|
||||
resolve(payload as unknown as ToolCalledEvent)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function parsedToolResult(response: Response): Promise<{ isError: boolean; payload: Record<string, unknown> }> {
|
||||
const json = await response.json()
|
||||
const result = json.result as { isError?: boolean; content: { text: string }[] }
|
||||
return { isError: result.isError === true, payload: JSON.parse(result.content[0].text) }
|
||||
}
|
||||
|
||||
describe('MCP test-key write guard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
it('blocks a non-simulatable write tool for a test-mode key — before execute()', async () => {
|
||||
const eventPromise = captureNextToolCalled()
|
||||
|
||||
// approve has readOnlyHint:false and no dry_run param → cannot be simulated.
|
||||
const response = await handleMcpRequest(
|
||||
mcpToolCall('gnubok_approve_pending_operation', { operation_id: 'op-1' }),
|
||||
)
|
||||
const { isError } = await parsedToolResult(response)
|
||||
|
||||
expect(isError).toBe(true)
|
||||
const event = await eventPromise
|
||||
expect(event.errorKind).toBe('test_key_write_blocked')
|
||||
expect(event.success).toBe(false)
|
||||
// Exits before tool.execute() — no pending op is ever committed.
|
||||
expect(event.latencyMs).toBe(0)
|
||||
})
|
||||
|
||||
it('lets a read-only tool through for a test-mode key', async () => {
|
||||
const eventPromise = captureNextToolCalled()
|
||||
|
||||
await handleMcpRequest(mcpToolCall('gnubok_list_skills', {}))
|
||||
|
||||
const event = await eventPromise
|
||||
// Whatever happens inside execute(), the test-key guard must NOT fire on a
|
||||
// read tool.
|
||||
expect(event.errorKind).not.toBe('test_key_write_blocked')
|
||||
})
|
||||
})
|
||||
@@ -64,6 +64,7 @@ import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
|
||||
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { findMatchingInvoices } from '@/lib/invoices/invoice-matching'
|
||||
import { listRotRutCandidates, createRotRutPayoutRequest } from '@/lib/invoices/rot-rut-service'
|
||||
import {
|
||||
findMatchingVouchersForInvoice,
|
||||
validateVoucherForInvoiceLink,
|
||||
@@ -9057,6 +9058,135 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_generate_rot_rut_file',
|
||||
title: 'Generate Rot/Rut Payout File',
|
||||
description:
|
||||
'Begäran om utbetalning for rot/rut (Skatteverket husavdrag): XML file from paid deduction invoices, uploaded manually on skatteverket.se (no API exists). Call with list_only=true first to see eligible invoices and blockers. Generating records an active begäran per invoice.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
deduction_type: { type: 'string', enum: ['rot', 'rut'] },
|
||||
list_only: {
|
||||
type: 'boolean',
|
||||
description: 'Only list eligible + blocked invoices, generate nothing (default false)',
|
||||
},
|
||||
invoice_ids: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Invoices to include. Omitted = all currently eligible.',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
maxLength: 16,
|
||||
description: 'NamnPaBegaran shown in Skatteverkets e-tjänst (max 16 chars). Omitted = generated.',
|
||||
},
|
||||
},
|
||||
required: ['deduction_type'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
deduction_type: { type: 'string' },
|
||||
eligible: { type: 'array', items: { type: 'object' } },
|
||||
blocked: {
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
description: 'Invoices excluded from begäran with per-invoice blocker code + Swedish message',
|
||||
},
|
||||
generated: { type: 'boolean' },
|
||||
request_id: { type: ['string', 'null'] },
|
||||
file_name: { type: ['string', 'null'] },
|
||||
xml: { type: ['string', 'null'], description: 'File content — save as UTF-8 .xml and upload on skatteverket.se' },
|
||||
requested_total: { type: 'number' },
|
||||
arenden: { type: 'array', items: { type: 'object' } },
|
||||
warnings: { type: 'array', items: { type: 'string' } },
|
||||
upload_url: { type: 'string' },
|
||||
},
|
||||
required: ['deduction_type', 'generated'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false, // records a rot_rut_payout_requests row when generating
|
||||
destructiveHint: false,
|
||||
idempotentHint: false, // second call conflicts (one active begäran per invoice)
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const type = args.deduction_type as 'rot' | 'rut'
|
||||
if (type !== 'rot' && type !== 'rut') throw new Error('deduction_type must be rot or rut')
|
||||
const uploadUrl = 'https://www7.skatteverket.se/portal/rotrut/begar-utbetalning/fil'
|
||||
|
||||
const candidates = await listRotRutCandidates(supabase, companyId, type)
|
||||
if (!candidates.ok) throw new Error('Failed to list rot/rut candidates')
|
||||
|
||||
if (args.list_only === true) {
|
||||
return {
|
||||
deduction_type: type,
|
||||
eligible: candidates.eligible,
|
||||
blocked: candidates.blocked,
|
||||
generated: false,
|
||||
request_id: null,
|
||||
file_name: null,
|
||||
xml: null,
|
||||
requested_total: candidates.eligible.reduce((sum, e) => sum + e.begart_belopp, 0),
|
||||
warnings: [],
|
||||
upload_url: uploadUrl,
|
||||
}
|
||||
}
|
||||
|
||||
const requestedIds = Array.isArray(args.invoice_ids) && args.invoice_ids.length > 0
|
||||
? (args.invoice_ids as string[])
|
||||
: candidates.eligible.map((e) => e.invoice_id)
|
||||
if (requestedIds.length === 0) {
|
||||
return {
|
||||
deduction_type: type,
|
||||
eligible: [],
|
||||
blocked: candidates.blocked,
|
||||
generated: false,
|
||||
request_id: null,
|
||||
file_name: null,
|
||||
xml: null,
|
||||
requested_total: 0,
|
||||
warnings: ['Inga fakturor är redo att begäras. Se blocked för orsaker per faktura.'],
|
||||
upload_url: uploadUrl,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await createRotRutPayoutRequest(supabase, companyId, userId, {
|
||||
type,
|
||||
invoiceIds: requestedIds,
|
||||
name: typeof args.name === 'string' ? args.name : undefined,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
const blockerLines = (result.blockers ?? [])
|
||||
.map((b) => `${b.invoice_number ?? b.invoice_id}: ${b.message}`)
|
||||
.join(' | ')
|
||||
throw new Error(
|
||||
result.code === 'ROT_RUT_INVOICE_CONFLICT'
|
||||
? 'Minst en faktura ingår redan i en aktiv begäran om utbetalning.'
|
||||
: `Filen kunde inte skapas (${result.code}).${blockerLines ? ` ${blockerLines}` : ''}`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
deduction_type: type,
|
||||
eligible: candidates.eligible,
|
||||
blocked: candidates.blocked,
|
||||
generated: true,
|
||||
request_id: result.request.id as string,
|
||||
file_name: result.file.file_name,
|
||||
xml: result.file.xml,
|
||||
requested_total: result.file.requested_total,
|
||||
arenden: result.file.arenden,
|
||||
warnings: result.file.warnings,
|
||||
upload_url: uploadUrl,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_audit_package',
|
||||
title: 'Generate Audit Package',
|
||||
@@ -11386,7 +11516,7 @@ function emitToolCallTelemetry(payload: {
|
||||
success: boolean
|
||||
isError: boolean
|
||||
errorCode: string | null
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | null
|
||||
errorKind: 'execution' | 'scope_denied' | 'capability_denied' | 'unknown_tool' | 'test_key_write_blocked' | null
|
||||
errorMessage: string | null
|
||||
requestId: string | number | null
|
||||
userId: string
|
||||
@@ -11650,7 +11780,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
})
|
||||
}
|
||||
|
||||
const { userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName } = authResult
|
||||
const { userId, companyId, scopes: keyScopes, apiKeyId, apiKeyName, mode: keyMode } = authResult
|
||||
const supabase = createServiceClientNoCookies()
|
||||
// The Mcp-Session-Id header (introduced in spec 2025-06-18) is the canonical
|
||||
// way for an agent to keep a stable identifier across tools/call invocations
|
||||
@@ -11870,6 +12000,47 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
|
||||
)
|
||||
}
|
||||
|
||||
// Test-mode API keys are simulation-only. Mirror the v1 REST guard
|
||||
// (lib/api/v1/with-api-v1.ts): force dry-run on any write tool that
|
||||
// supports it, and block writes that cannot be simulated. Without this a
|
||||
// gnubok_sk_test_ key — which is bound to the real active company — could
|
||||
// stage real pending_operations here and, with the approve scope, commit
|
||||
// them. Runs before execute() so nothing is ever staged for a test key.
|
||||
if (keyMode === 'test' && tool.annotations?.readOnlyHint === false) {
|
||||
const props = (tool.inputSchema as { properties?: Record<string, unknown> } | undefined)
|
||||
?.properties
|
||||
if (props && 'dry_run' in props) {
|
||||
;(toolArgs as Record<string, unknown>).dry_run = true
|
||||
} else {
|
||||
const blocked = toToolError(
|
||||
new Error(
|
||||
'Test-nyckel kan inte utföra riktiga skrivningar mot det här verktyget. Använd en live-nyckel för skarpa operationer.'
|
||||
),
|
||||
{ toolName }
|
||||
)
|
||||
emitToolCallTelemetry({
|
||||
tool: toolName,
|
||||
requiredScope: requiredScope ?? null,
|
||||
actor,
|
||||
latencyMs: 0,
|
||||
success: false,
|
||||
isError: true,
|
||||
errorCode: blocked.error.code,
|
||||
errorKind: 'test_key_write_blocked',
|
||||
errorMessage: blocked.error.message_sv,
|
||||
requestId: id ?? null,
|
||||
userId,
|
||||
companyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
jsonRpc(id ?? null, {
|
||||
content: [{ type: 'text', text: JSON.stringify(blocked, null, 2) }],
|
||||
isError: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if THIS call follows the previous call's `next` hint — must
|
||||
// run before execute() so we don't double-store on this call. Emits
|
||||
// mcp.next_hint_followed when the agent's behaviour matches the hint.
|
||||
|
||||
Reference in New Issue
Block a user