Files
accounted/lib/support/attachments.ts
T
Mattsson 00e7ac92ae 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.
2026-08-26 12:32:29 +02:00

82 lines
2.9 KiB
TypeScript

/**
* 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}`
}