feat(support): attach images and PDFs to the in-app contact form

Add optional image and PDF attachments to the existing in-app support contact form, with client-side limits, server-side validation, and email delivery. Preserve the existing subject, rate-limit, analytics, and storage behavior.
This commit is contained in:
Mattsson
2026-08-26 12:32:29 +02:00
committed by GitHub
parent 151fb1384c
commit 00e7ac92ae
10 changed files with 797 additions and 21 deletions
+1
View File
@@ -1245,6 +1245,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-25] Payslip YTD ("Ackumulerat") stays a stored snapshot on salary_run_employees, refreshed at approve + book, rather than being recomputed at PDF-render time: an employee who re-downloads a lonebesked must see the figures it had when it was issued, and a render-time sum would silently restate delivered payslips after any backdated correction. The same change widens the counted prior-run statuses from booked-only to approved/paid/booked (corrected stays excluded: its correction run replaces the whole month), because the original snapshot-at-calculate-time rule froze a YTD that was missing every month not yet booked when next month's run was prepared.
[2026-08-25] ROT/RUT BegartBelopp truncates to whole kronor (truncateToWholeKronor), not half-up: the deduction is capped at 50%/30% of arbetskostnaden. At the RUT cap, half-up manufactured begart > betalt and blocked correct invoices; for ROT below the cap it over-requested past the cap and the 1513 fordran, so those files now ask 1 kr less (skeptic-verified on PR #1910).
[2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent.
[2026-08-26] Support-dialog attachments use the existing email delivery path without storage or schema changes: this keeps the feature scoped to the contact form. The budget is 5 files / 4 MB total under the 4.5 MB hosted request-body ceiling, with client-side image shrinking when needed.
[2026-08-26] RFC 9728 protected-resource metadata is served at THREE locations (root, path-based /.well-known/oauth-protected-resource/<mcp path>, and <mcp url>/.well-known/oauth-protected-resource): Claude.ai's connector setup derives the metadata URL from the server URL and fetches it before any 401, so the root document our WWW-Authenticate header points at was not enough ('Authorization with Accounted failed' with only 404s in the logs). One builder, three routes; the path-based route answers 404 for any path other than the MCP endpoint so no phantom resource is advertised.
[2026-08-20] The swedish-e-invoicing skill now names Upphandlingsmyndigheten as Sweden Peppol Authority across all eight files, not just the one that was flagged: the handover completed 1 July 2026 (regeringsbeslut Fi2025/01826) and the skill was written in future tense, so a partial fix would have left the atom internally contradictory and still pointed agents at peppol@digg.se. Four digg.se URLs were repointed to their verified 301 targets on upphandlingsmyndigheten.se; the fifth, DIGG Peppol testbadd, is a hard 404 with no redirect and no successor page at the new authority, so it was replaced with the SFTI Validex verification service (https://sfti.validex.net/) rather than left dead or guessed at. Historical attributions (Q4 2025 traffic statistics, the 0007:2021006883 Peppol-ID example) deliberately still say DIGG because they were accurate when published.
[2026-08-26] gnubok_connect_bank / gnubok_connect_skatteverket moved from catalogVisibility 'search' to the default catalog: Claude.ai can only invoke tools present in tools/list, so search-only tools are discover-only there and the onboarding skill's steps 3-4 dead-ended on client-side tool-not-found (verified via event_log: the server never received the calls). Search-only visibility remains fine for tools an agent reads about before asking the user, but anything a skill instructs the agent to CALL must be in the default catalog.
@@ -0,0 +1,238 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockSupabase } from '@/tests/helpers'
import {
SUPPORT_MAX_ATTACHMENTS,
SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES,
} from '@/lib/support/attachments'
const mockSupabase = createMockSupabase()
const requireAuthMock = vi.fn()
const sendEmailMock = vi.fn()
const isConfiguredMock = vi.fn()
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
sendEmail: (...args: unknown[]) => sendEmailMock(...args),
isConfigured: () => isConfiguredMock(),
}),
}))
vi.mock('@/lib/support', () => ({
getSupportRecipientEmail: () => 'support@example.test',
}))
vi.mock('@/lib/branding/service', () => ({
getBranding: () => ({ appName: 'Accounted' }),
}))
import { POST } from '../route'
const user = { id: 'user-1', email: 'user@example.test' }
/** Real PNG signature: a declared image/png with anything else is rejected. */
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d])
function pngFile(name = 'skarmbild.png', padTo = 0): File {
const bytes = padTo > PNG_BYTES.length
? new Uint8Array([...PNG_BYTES, ...new Uint8Array(padTo - PNG_BYTES.length)])
: PNG_BYTES
return new File([bytes], name, { type: 'image/png' })
}
function jsonRequest(body: unknown): Request {
return new Request('http://localhost/api/support/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
function multipartRequest(fields: {
subject?: string
message?: string
files?: File[]
}): Request {
const form = new FormData()
if (fields.subject !== undefined) form.append('subject', fields.subject)
if (fields.message !== undefined) form.append('message', fields.message)
for (const file of fields.files ?? []) form.append('files', file, file.name)
return new Request('http://localhost/api/support/contact', { method: 'POST', body: form })
}
describe('POST /api/support/contact', () => {
beforeEach(() => {
vi.clearAllMocks()
isConfiguredMock.mockReturnValue(true)
sendEmailMock.mockResolvedValue({ success: true })
requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(jsonRequest({ message: 'Hjälp tack' }))
expect(response.status).toBe(401)
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('rejects a message shorter than 5 characters', async () => {
const response = await POST(jsonRequest({ message: 'hej' }))
expect(response.status).toBe(400)
})
it('maps an invalid request body to a Swedish user-facing error', async () => {
const response = await POST(
new Request('http://localhost/api/support/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{',
})
)
expect(response.status).toBe(400)
expect((await response.json()).error).toBe('Förfrågan innehåller ogiltiga uppgifter.')
})
it('still accepts the JSON body with no attachments', async () => {
const response = await POST(jsonRequest({ subject: 'Moms', message: 'Jag fastnar på ruta 05' }))
expect(response.status).toBe(200)
expect(sendEmailMock).toHaveBeenCalledWith(
expect.objectContaining({
to: 'support@example.test',
replyTo: 'user@example.test',
attachments: undefined,
})
)
})
it('attaches an uploaded screenshot to the support mail', async () => {
const response = await POST(
multipartRequest({ subject: 'Trasig vy', message: 'Ser ut så här', files: [pngFile()] })
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ data: { sent: true } })
const sent = sendEmailMock.mock.calls[0][0]
expect(sent.subject).toBe('[accounted support] Trasig vy')
expect(sent.attachments).toHaveLength(1)
expect(sent.attachments[0].filename).toBe('skarmbild.png')
expect(sent.attachments[0].contentType).toBe('image/png')
expect(Buffer.isBuffer(sent.attachments[0].content)).toBe(true)
// The reader must see that files came along even in a folded mail client.
expect(sent.text).toContain('Bilagor (1): skarmbild.png')
})
it('strips path segments out of the attachment filename', async () => {
await POST(
multipartRequest({
message: 'Se bilagan',
files: [pngFile('../../etc/passwd.png')],
})
)
expect(sendEmailMock.mock.calls[0][0].attachments[0].filename).toBe('passwd.png')
})
it('forces an attachment extension that matches the verified type', async () => {
await POST(
multipartRequest({
message: 'Se bilagan',
files: [pngFile('update.exe')],
})
)
expect(sendEmailMock.mock.calls[0][0].attachments[0].filename).toBe('update.png')
})
it('rejects an empty attachment instead of silently omitting it', async () => {
const response = await POST(
multipartRequest({
message: 'Tom bild',
files: [new File([], 'tom.png', { type: 'image/png' })],
})
)
expect(response.status).toBe(400)
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('rejects more attachments than the cap allows', async () => {
const files = Array.from(
{ length: SUPPORT_MAX_ATTACHMENTS + 1 },
(_, index) => pngFile(`${index}.png`)
)
const response = await POST(
multipartRequest({
message: 'För många bilder',
files,
})
)
expect(response.status).toBe(400)
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('rejects attachments over the total size budget', async () => {
const response = await POST(
multipartRequest({
message: 'En stor bild',
files: [pngFile('stor.png', SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES + 1)],
})
)
expect(response.status).toBe(400)
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('rejects an unsupported attachment type', async () => {
const response = await POST(
multipartRequest({
message: 'Ett skript',
files: [new File(['#!/bin/sh'], 'run.sh', { type: 'application/x-sh' })],
})
)
expect(response.status).toBe(400)
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('rejects content that does not match its declared type', async () => {
const response = await POST(
multipartRequest({
message: 'Utger sig för att vara en png',
files: [new File(['not a png at all'], 'fake.png', { type: 'image/png' })],
})
)
expect(response.status).toBe(400)
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('rejects an executable-shaped PDF polyglot', async () => {
const bytes = new Uint8Array(64)
bytes.set([0x4d, 0x5a])
bytes.set([0x25, 0x50, 0x44, 0x46, 0x2d], 16)
const response = await POST(
multipartRequest({
message: 'Misstänkt PDF',
files: [new File([bytes], 'update.exe', { type: 'application/pdf' })],
})
)
expect(response.status).toBe(400)
expect(sendEmailMock).not.toHaveBeenCalled()
})
it('returns 503 when the email service is not configured', async () => {
isConfiguredMock.mockReturnValue(false)
const response = await POST(jsonRequest({ message: 'Hjälp tack' }))
expect(response.status).toBe(503)
})
it('returns 500 when the send fails', async () => {
sendEmailMock.mockResolvedValue({ success: false, error: 'boom' })
const response = await POST(jsonRequest({ message: 'Hjälp tack' }))
expect(response.status).toBe(500)
})
})
+127 -8
View File
@@ -2,9 +2,18 @@ import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { getEmailService } from '@/lib/email/service'
import { getSupportRecipientEmail } from '@/lib/support'
import {
SUPPORT_MAX_ATTACHMENTS,
SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES,
SUPPORT_MAX_ATTACHMENT_TOTAL_MB,
isSupportedAttachmentType,
supportAttachmentFilename,
} from '@/lib/support/attachments'
import { validateDocumentMagicBytes } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { ensureInitialized } from '@/lib/init'
import { getBranding } from '@/lib/branding/service'
import { getErrorMessage } from '@/lib/errors/get-error-message'
ensureInitialized()
@@ -12,20 +21,110 @@ function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
interface ParsedAttachment {
filename: string
content: Buffer
contentType: string
}
function hasUnsafePdfPrefix(buffer: ArrayBuffer): boolean {
const bytes = new Uint8Array(buffer)
const signatures = [
[0x4d, 0x5a], // Windows executable
[0x7f, 0x45, 0x4c, 0x46], // ELF executable
[0x50, 0x4b, 0x03, 0x04], // ZIP container
[0x23, 0x21], // Executable script
]
return signatures.some(
(signature) =>
signature.length <= bytes.length &&
signature.every((byte, index) => bytes[index] === byte)
)
}
/**
* Attachments are relayed through the existing email service. Their bytes are
* checked against the declared type instead of trusting multipart headers.
*/
async function parseAttachments(
files: File[]
): Promise<{ attachments: ParsedAttachment[] } | { error: string }> {
if (files.length > SUPPORT_MAX_ATTACHMENTS) {
return { error: `Du kan bifoga max ${SUPPORT_MAX_ATTACHMENTS} filer` }
}
const attachments: ParsedAttachment[] = []
let totalBytes = 0
for (const file of files) {
const fileType = file.type.toLowerCase()
if (!isSupportedAttachmentType(fileType)) {
return { error: 'Bifogade filer måste vara bilder (JPG, PNG, WEBP) eller PDF' }
}
totalBytes += file.size
if (totalBytes > SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES) {
return { error: `Bilagorna får väga max ${SUPPORT_MAX_ATTACHMENT_TOTAL_MB} MB tillsammans` }
}
const buffer = await file.arrayBuffer()
if (fileType === 'application/pdf' && hasUnsafePdfPrefix(buffer)) {
return { error: 'PDF-filen har ett ogiltigt innehåll' }
}
const magicError = validateDocumentMagicBytes(buffer, fileType)
if (magicError) return { error: magicError }
attachments.push({
filename: supportAttachmentFilename(file.name, fileType),
content: Buffer.from(buffer),
contentType: fileType,
})
}
return { attachments }
}
export async function POST(request: Request) {
const { user, supabase, error } = await requireAuth()
if (error) return error
await requireCompanyId(supabase, user.id)
let body: { subject?: string; message?: string }
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
let subjectRaw: string | undefined
let messageRaw: string | undefined
let files: File[] = []
const contentType = request.headers.get('content-type') || ''
if (contentType.includes('multipart/form-data')) {
let form: FormData
try {
form = await request.formData()
} catch {
return NextResponse.json(
{ error: getErrorMessage('Invalid request body', { statusCode: 400 }) },
{ status: 400 }
)
}
const subjectField = form.get('subject')
const messageField = form.get('message')
subjectRaw = typeof subjectField === 'string' ? subjectField : undefined
messageRaw = typeof messageField === 'string' ? messageField : undefined
files = form.getAll('files').filter((f): f is File => f instanceof File)
} else {
let body: { subject?: string; message?: string }
try {
body = await request.json()
} catch {
return NextResponse.json(
{ error: getErrorMessage('Invalid request body', { statusCode: 400 }) },
{ status: 400 }
)
}
subjectRaw = body.subject
messageRaw = body.message
}
const message = body.message?.trim()
const message = messageRaw?.trim()
if (!message || message.length < 5) {
return NextResponse.json({ error: 'Meddelandet måste vara minst 5 tecken' }, { status: 400 })
}
@@ -33,7 +132,16 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Meddelandet får vara max 5000 tecken' }, { status: 400 })
}
const subject = body.subject?.trim() || 'Supportärende'
const subject = subjectRaw?.trim() || 'Supportärende'
const parsed = await parseAttachments(files)
if ('error' in parsed) {
return NextResponse.json(
{ error: getErrorMessage(parsed.error, { statusCode: 400 }) },
{ status: 400 }
)
}
const { attachments } = parsed
const emailService = getEmailService()
if (!emailService.isConfigured()) {
@@ -45,6 +153,15 @@ export async function POST(request: Request) {
const safeSubject = escapeHtml(subject)
const safeMessage = escapeHtml(message).replace(/\n/g, '<br />')
// Named in the body as well as attached: a mail client that folds
// attachments away otherwise hides the fact that they exist at all.
const attachmentNames = attachments.map((a) => a.filename)
const attachmentHtml = attachmentNames.length
? `<hr /><p><strong>Bilagor (${attachmentNames.length}):</strong> ${escapeHtml(attachmentNames.join(', '))}</p>`
: ''
const attachmentText = attachmentNames.length
? `\n\nBilagor (${attachmentNames.length}): ${attachmentNames.join(', ')}`
: ''
const result = await emailService.sendEmail({
to: getSupportRecipientEmail(),
@@ -56,8 +173,10 @@ export async function POST(request: Request) {
<p><strong>Ämne:</strong> ${safeSubject}</p>
<hr />
<p>${safeMessage}</p>
${attachmentHtml}
`,
text: `Från: ${user.email}\nUser ID: ${user.id}\nÄmne: ${subject}\n\n${message}`,
text: `Från: ${user.email}\nUser ID: ${user.id}\nÄmne: ${subject}\n\n${message}${attachmentText}`,
attachments: attachments.length ? attachments : undefined,
})
if (!result.success) {
+157 -5
View File
@@ -1,9 +1,9 @@
'use client'
import { useState } from 'react'
import { useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Mail, Loader2, Send } from 'lucide-react'
import { FileText, Loader2, Mail, Paperclip, Send, X } from 'lucide-react'
import {
Dialog,
DialogContent,
@@ -18,6 +18,15 @@ import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { submitFeedback } from '@/lib/support/submit-feedback'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import {
SUPPORT_ATTACHMENT_ACCEPT,
SUPPORT_MAX_ATTACHMENTS,
SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES,
SUPPORT_MAX_ATTACHMENT_TOTAL_MB,
isSupportedAttachmentType,
} from '@/lib/support/attachments'
import { shrinkImageForUpload } from '@/lib/documents/shrink-image'
import { isShrinkableImage } from '@/lib/documents/upload-size'
interface SupportLinkProps {
variant?: 'inline' | 'muted'
@@ -26,6 +35,12 @@ interface SupportLinkProps {
className?: string
}
type AttachmentError = 'unsupported' | 'too_many' | 'too_large'
function totalBytes(files: File[]): number {
return files.reduce((sum, file) => sum + file.size, 0)
}
export function SupportLink({
variant = 'inline',
subject,
@@ -35,19 +50,91 @@ export function SupportLink({
const t = useTranslations('support_link')
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
const [attachments, setAttachments] = useState<File[]>([])
const [isPreparing, setIsPreparing] = useState(false)
const [isSending, setIsSending] = useState(false)
const [sent, setSent] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const attachmentGenerationRef = useRef(0)
const { toast } = useToast()
const companyCtx = useCompanyOptional()
if (companyCtx?.isSandbox) return null
function showAttachmentError(error: AttachmentError) {
if (error === 'unsupported') {
toast({ title: t('attach_unsupported'), variant: 'destructive' })
return
}
if (error === 'too_many') {
toast({
title: t('attach_too_many', { count: SUPPORT_MAX_ATTACHMENTS }),
variant: 'destructive',
})
return
}
toast({
title: t('attach_too_large', { limit: SUPPORT_MAX_ATTACHMENT_TOTAL_MB }),
variant: 'destructive',
})
}
async function addFiles(incoming: File[]) {
if (!incoming.length || isPreparing || isSending) return
const generation = attachmentGenerationRef.current
setIsPreparing(true)
try {
const next = [...attachments]
let firstError: AttachmentError | null = null
for (const original of incoming) {
if (!isSupportedAttachmentType(original.type)) {
firstError ??= 'unsupported'
continue
}
if (next.length >= SUPPORT_MAX_ATTACHMENTS) {
firstError ??= 'too_many'
break
}
const remaining = SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES - totalBytes(next)
const file = original.size > remaining && isShrinkableImage(original.type)
? await shrinkImageForUpload(original, remaining)
: original
if (file.size > remaining) {
firstError ??= 'too_large'
continue
}
next.push(file)
}
if (generation === attachmentGenerationRef.current) {
setAttachments(next)
if (firstError) showAttachmentError(firstError)
}
} finally {
setIsPreparing(false)
}
}
function resetAttachments() {
attachmentGenerationRef.current += 1
setAttachments([])
if (fileInputRef.current) fileInputRef.current.value = ''
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (message.trim().length < 5) return
setIsSending(true)
const result = await submitFeedback({ subject, message: message.trim() })
const result = await submitFeedback({
subject,
message: message.trim(),
files: attachments,
})
setIsSending(false)
if (result.ok) {
@@ -56,6 +143,7 @@ export function SupportLink({
setOpen(false)
setSent(false)
setMessage('')
resetAttachments()
}, 2000)
} else {
toast({
@@ -71,6 +159,7 @@ export function SupportLink({
if (!next) {
setSent(false)
setMessage('')
resetAttachments()
}
}
@@ -130,18 +219,81 @@ export function SupportLink({
<p className="text-xs text-muted-foreground mt-1.5">
{t('char_count', { count: message.length })}
</p>
{attachments.length > 0 ? (
<ul className="mt-3 space-y-2">
{attachments.map((file, index) => (
<li
key={`${file.name}-${file.size}-${index}`}
className="ph-no-capture flex min-w-0 items-center gap-2 rounded-lg border border-border px-3 py-1"
>
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-xs">
{file.name}
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setAttachments((current) => current.filter((_, i) => i !== index))}
disabled={isSending || isPreparing}
aria-label={t('remove_attachment')}
className="shrink-0"
>
<X className="h-4 w-4" />
</Button>
</li>
))}
</ul>
) : null}
<div className="mt-3 flex flex-col items-start gap-2 sm:flex-row sm:items-center">
<input
ref={fileInputRef}
type="file"
multiple
accept={SUPPORT_ATTACHMENT_ACCEPT}
className="hidden"
disabled={isSending || isPreparing}
onChange={(e) => {
void addFiles(Array.from(e.target.files ?? []))
e.target.value = ''
}}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={isSending || isPreparing || attachments.length >= SUPPORT_MAX_ATTACHMENTS}
onClick={() => fileInputRef.current?.click()}
>
{isPreparing ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Paperclip className="mr-2 h-3.5 w-3.5" />
)}
{t('attach_label')}
</Button>
<span className="text-xs text-muted-foreground">
{t('attach_hint', {
count: SUPPORT_MAX_ATTACHMENTS,
limit: SUPPORT_MAX_ATTACHMENT_TOTAL_MB,
})}
</span>
</div>
<DialogFooter className="mt-4">
<Button
type="button"
variant="ghost"
onClick={() => setOpen(false)}
onClick={() => handleOpenChange(false)}
disabled={isSending}
>
{t('cancel')}
</Button>
<Button
type="submit"
disabled={isSending || message.trim().length < 5}
disabled={isSending || isPreparing || message.trim().length < 5}
>
{isSending ? (
<>
+94
View File
@@ -0,0 +1,94 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, it, expect } from 'vitest'
import {
SUPPORT_ATTACHMENT_ACCEPT,
SUPPORT_MAX_ATTACHMENTS,
SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES,
SUPPORT_MAX_ATTACHMENT_TOTAL_MB,
isSupportedAttachmentType,
sanitizeAttachmentFilename,
supportAttachmentFilename,
} from '@/lib/support/attachments'
import { HOSTED_REQUEST_BODY_LIMIT_BYTES } from '@/lib/documents/upload-size'
describe('support attachments', () => {
it('accepts the types a support reader can open', () => {
expect(isSupportedAttachmentType('image/png')).toBe(true)
expect(isSupportedAttachmentType('IMAGE/JPEG')).toBe(true)
expect(isSupportedAttachmentType('application/pdf')).toBe(true)
})
it('rejects everything else, including a missing type', () => {
expect(isSupportedAttachmentType('application/x-sh')).toBe(false)
expect(isSupportedAttachmentType('image/heic')).toBe(false)
expect(isSupportedAttachmentType(undefined)).toBe(false)
expect(isSupportedAttachmentType('')).toBe(false)
})
it('offers the same list to the file picker', () => {
expect(SUPPORT_ATTACHMENT_ACCEPT).toBe('image/jpeg,image/png,image/webp,application/pdf')
})
it('allows five files with a four megabyte combined budget', () => {
expect(SUPPORT_MAX_ATTACHMENTS).toBe(5)
expect(SUPPORT_MAX_ATTACHMENT_TOTAL_MB).toBe(4)
expect(SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES).toBe(4 * 1024 * 1024)
})
// Vercel kills the request before the route runs if the body is over its own
// ceiling, so the budget has to leave room for the multipart envelope.
it('stays under the platform request-body ceiling', () => {
expect(SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES).toBeLessThan(HOSTED_REQUEST_BODY_LIMIT_BYTES)
})
describe('sanitizeAttachmentFilename', () => {
it('keeps an ordinary name', () => {
expect(sanitizeAttachmentFilename('skarmbild.png')).toBe('skarmbild.png')
})
it('drops path segments from both separators', () => {
expect(sanitizeAttachmentFilename('../../etc/passwd.png')).toBe('passwd.png')
expect(sanitizeAttachmentFilename('C:\\Users\\emil\\bild.png')).toBe('bild.png')
})
it('strips control characters that would break a mail header', () => {
const withNewline = `bild${String.fromCharCode(13)}${String.fromCharCode(10)}.png`
expect(sanitizeAttachmentFilename(withNewline)).toBe('bild.png')
})
it('falls back when nothing usable is left', () => {
expect(sanitizeAttachmentFilename('')).toBe('bilaga')
expect(sanitizeAttachmentFilename(null)).toBe('bilaga')
expect(sanitizeAttachmentFilename(String.fromCharCode(0))).toBe('bilaga')
})
it('bounds a very long name but keeps the extension', () => {
const long = `${'a'.repeat(300)}.png`
const result = sanitizeAttachmentFilename(long)
expect(result.length).toBeLessThanOrEqual(100)
expect(result.endsWith('.png')).toBe(true)
})
})
describe('supportAttachmentFilename', () => {
it('forces the extension to agree with the verified MIME type', () => {
expect(supportAttachmentFilename('update.exe', 'application/pdf')).toBe('update.pdf')
expect(supportAttachmentFilename('photo.png', 'image/jpeg')).toBe('photo.jpg')
})
it('keeps the final filename within the mail-header bound', () => {
const filename = supportAttachmentFilename('a'.repeat(150), 'application/pdf')
expect(filename.length).toBeLessThanOrEqual(100)
})
})
it('does not expose attachment names in session-replay attributes', () => {
const source = readFileSync(
join(process.cwd(), 'components', 'ui', 'support-link.tsx'),
'utf8'
)
expect(source).toContain('ph-no-capture')
expect(source).not.toContain('title={file.name}')
})
})
@@ -271,5 +271,63 @@ describe('submitFeedback', () => {
await submitFeedback({ subject: 'Moms', message: 'hemlig fritext om bolaget' })
expect(JSON.stringify(captureMock.mock.calls)).not.toContain('hemlig fritext')
})
it('does not add attachment metadata to analytics', async () => {
stubFetchOk()
await submitFeedback({
message: 'Se bilagan',
files: [new File(['x'], 'kontoutdrag-privat.png', { type: 'image/png' })],
})
const props = captureMock.mock.calls[0][1] as Record<string, unknown>
expect(props).not.toHaveProperty('attachment_count')
expect(JSON.stringify(captureMock.mock.calls)).not.toContain('kontoutdrag-privat')
})
})
describe('attachments', () => {
it('sends multipart with the files when there are any', async () => {
const fetchSpy = stubFetchOk()
const file = new File(['x'], 'skarmbild.png', { type: 'image/png' })
await submitFeedback({ subject: 'Trasig vy', message: 'Ser ut så här', files: [file] })
const init = fetchSpy.mock.calls[0][1]
expect(init.body).toBeInstanceOf(FormData)
// The browser has to set the multipart boundary itself.
expect(init.headers).toBeUndefined()
const form = init.body as FormData
expect(form.get('subject')).toBe('Trasig vy')
expect(form.get('message')).toBe('Ser ut så här')
expect(form.getAll('files')).toHaveLength(1)
expect((form.get('files') as File).name).toBe('skarmbild.png')
})
it('keeps the JSON body when the file list is empty', async () => {
const fetchSpy = stubFetchOk()
await submitFeedback({ subject: 'Moms', message: 'Jag fastnar', files: [] })
expect(fetchSpy.mock.calls[0][1].body).toBe(
JSON.stringify({ subject: 'Moms', message: 'Jag fastnar' })
)
})
it('surfaces the route error when an attachment is rejected', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ error: 'Du kan bifoga max 5 filer' }),
})
)
const result = await submitFeedback({
message: 'För många bilder',
files: [new File(['x'], 'a.png', { type: 'image/png' })],
})
expect(result.ok).toBe(false)
expect(result.error).toBe('Du kan bifoga max 5 filer')
})
})
})
+81
View File
@@ -0,0 +1,81 @@
/**
* Support attachment rules, shared by the dialog and the /api/support/contact
* route so the client never offers to send something the server will reject.
*
* Attachments ride along on the existing support email. This module only
* defines the transport limits and does not add a storage path.
*/
/** What a support reader can actually open without extra tooling. */
export const SUPPORT_ATTACHMENT_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
] as const
const SUPPORT_ATTACHMENT_EXTENSIONS: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'application/pdf': '.pdf',
}
export const SUPPORT_MAX_ATTACHMENTS = 5
/**
* Total bytes across all attachments in one message.
*
* Vercel rejects a request body over 4.5 MB before the function runs (see
* lib/documents/upload-size.ts), so the ceiling has to leave room for the
* multipart envelope and the message text on top of the files themselves.
* Self-hosted has no such proxy limit, but the same cap applies there: a
* support mailbox is not a file transfer service.
*/
export const SUPPORT_MAX_ATTACHMENT_TOTAL_MB = 4
export const SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES = SUPPORT_MAX_ATTACHMENT_TOTAL_MB * 1024 * 1024
export function isSupportedAttachmentType(type: string | null | undefined): boolean {
return (SUPPORT_ATTACHMENT_TYPES as readonly string[]).includes(String(type ?? '').toLowerCase())
}
/** The `accept` attribute for the file picker: same list, one source of truth. */
export const SUPPORT_ATTACHMENT_ACCEPT = SUPPORT_ATTACHMENT_TYPES.join(',')
/**
* Make a client-supplied name safe to put in a mail header: no path
* separators, no control characters, bounded length. The extension is kept
* when there is one so the attachment still opens with the right app.
*
* Control characters are stripped by code point rather than by a regex class:
* a literal control character in a source file is invisible in review and has
* corrupted this repo's files before.
*/
export function sanitizeAttachmentFilename(raw: string | undefined | null): string {
const base = (String(raw ?? '').split(/[\\/]/).pop() ?? '')
.split('')
.filter((ch) => {
const code = ch.charCodeAt(0)
return code >= 0x20 && code !== 0x7f
})
.join('')
.trim()
if (!base) return 'bilaga'
return base.length > 100 ? `${base.slice(0, 80)}-${base.slice(-16)}` : base
}
/**
* Give the mail attachment an extension that agrees with the verified MIME
* type. A browser-supplied name must not make a PDF look executable.
*/
export function supportAttachmentFilename(
raw: string | undefined | null,
type: string
): string {
const safe = sanitizeAttachmentFilename(raw)
const extension = SUPPORT_ATTACHMENT_EXTENSIONS[type.toLowerCase()] ?? '.bin'
const lastDot = safe.lastIndexOf('.')
const stem = (lastDot > 0 ? safe.slice(0, lastDot) : safe) || 'bilaga'
return `${stem.slice(0, 100 - extension.length)}${extension}`
}
+27 -6
View File
@@ -4,6 +4,8 @@ import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
export interface SubmitFeedbackInput {
message: string
subject?: string
/** Screenshots or PDFs relayed on the support email only. */
files?: File[]
}
/**
@@ -27,15 +29,34 @@ export interface SubmitFeedbackResult {
error?: string
}
async function submitViaEmail(
{ message, subject }: SubmitFeedbackInput
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
const res = await fetch('/api/support/contact', {
/**
* JSON when there is nothing to attach, multipart when there is. The JSON path
* is kept byte-identical rather than always sending multipart: it is the shape
* every existing message uses, and a plain body is the one that still works if
* multipart parsing is ever the thing that broke.
*/
function buildEmailRequest({ message, subject, files }: SubmitFeedbackInput): RequestInit {
if (!files?.length) {
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject, message }),
})
}
}
const form = new FormData()
if (subject) form.append('subject', subject)
form.append('message', message)
for (const file of files) form.append('files', file, file.name)
// No Content-Type header: the browser has to set the multipart boundary.
return { method: 'POST', body: form }
}
async function submitViaEmail(
input: SubmitFeedbackInput
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
const res = await fetch('/api/support/contact', buildEmailRequest(input))
if (!res.ok) {
const data = await res.json().catch(() => ({}))
return { ok: false, error: data.error || 'Kunde inte skicka meddelandet' }
+7 -1
View File
@@ -4910,7 +4910,13 @@
"send": "Send",
"sending": "Sending...",
"send_failed_title": "Could not send",
"send_failed_fallback": "Please try again."
"send_failed_fallback": "Please try again.",
"attach_label": "Attach files",
"attach_hint": "JPG, PNG, WEBP or PDF. Up to {count} files, {limit} MB total.",
"attach_too_many": "You can attach at most {count} files",
"attach_too_large": "Attachments may total at most {limit} MB",
"attach_unsupported": "Only images (JPG, PNG, WEBP) and PDF can be attached",
"remove_attachment": "Remove attachment"
},
"journal_list": {
"import_attn": "This list contains vouchers from an SIE import. If something went wrong, the whole import can be undone.",
+7 -1
View File
@@ -4910,7 +4910,13 @@
"send": "Skicka",
"sending": "Skickar...",
"send_failed_title": "Kunde inte skicka",
"send_failed_fallback": "Försök igen."
"send_failed_fallback": "Försök igen.",
"attach_label": "Bifoga filer",
"attach_hint": "JPG, PNG, WEBP eller PDF. Max {count} filer, {limit} MB totalt.",
"attach_too_many": "Du kan bifoga max {count} filer",
"attach_too_large": "Bilagorna får väga max {limit} MB tillsammans",
"attach_unsupported": "Bara bilder (JPG, PNG, WEBP) och PDF kan bifogas",
"remove_attachment": "Ta bort bilaga"
},
"journal_list": {
"import_attn": "Listan innehåller verifikat från en SIE-import. Blev något fel kan hela importen ångras.",