From 95522fdeddb3a9fd6b6c95ca77e1128bedbcc4bb Mon Sep 17 00:00:00 2001 From: Oleg Date: Wed, 19 Aug 2026 09:07:40 +0000 Subject: [PATCH] Fix data-URI screenshots not opening on click (Chrome blocks data: URL navigation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The click-to-zoom feature from the previous commit worked for cid:-referenced images but silently failed for images embedded directly as a data: URI — confirmed with a real Playwright click: Chrome refuses to navigate a tab (even a new one, even from a direct user click) to a data: URL, so just does nothing. The cursor still showed zoom-in on hover since that's plain CSS, which is exactly the "лупа появляется, но не нажимается" symptom reported. Fix: imap.ts now extracts every data:image src out of an inbound email's HTML into a real attachment file (deduping identical images embedded more than once), the same way cid: images already were, and rewrites the HTML to point at that attachment's normal /api/attachments/... URL instead. That also shrinks messages.body_html (data URIs can be hundreds of KB sitting in a DB column) and gets data-URI images the same "no duplicate chip below" treatment cid: images already had. attachments.isInline is now its own real column (backfilled from the existing content_id-based cases) instead of being derived from content_id, since a data-URI-derived attachment is inline but was never cid-referenced. sanitizeEmailHtml's link-wrapping step now skips any residual data: src defensively (unwrapped-but-visible beats a link that looks clickable but isn't). Verified against the real deployment with actual browser clicks (Playwright): both a data-URI image and a cid: image now open their full-resolution attachment in a new tab; before this fix the data-URI one silently did nothing. Added a vitest.config.ts (needed for the new test file's @/ import aliases) and unit tests for the extraction/dedup logic. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GteWhnWKTmnXcsd5jx6H7u --- .../0009_little_natasha_romanoff.sql | 2 + src/lib/db/migrations/meta/0009_snapshot.json | 1058 +++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema.ts | 13 +- src/lib/mail/imap.test.ts | 35 + src/lib/mail/imap.ts | 97 +- src/lib/mail/sanitize-html.test.ts | 11 +- src/lib/mail/sanitize-html.ts | 10 +- src/lib/tickets/service.ts | 5 +- vitest.config.ts | 10 + 10 files changed, 1228 insertions(+), 20 deletions(-) create mode 100644 src/lib/db/migrations/0009_little_natasha_romanoff.sql create mode 100644 src/lib/db/migrations/meta/0009_snapshot.json create mode 100644 src/lib/mail/imap.test.ts create mode 100644 vitest.config.ts diff --git a/src/lib/db/migrations/0009_little_natasha_romanoff.sql b/src/lib/db/migrations/0009_little_natasha_romanoff.sql new file mode 100644 index 0000000..9550dec --- /dev/null +++ b/src/lib/db/migrations/0009_little_natasha_romanoff.sql @@ -0,0 +1,2 @@ +ALTER TABLE `attachments` ADD `is_inline` integer DEFAULT false NOT NULL;--> statement-breakpoint +UPDATE `attachments` SET `is_inline` = 1 WHERE `content_id` IS NOT NULL; \ No newline at end of file diff --git a/src/lib/db/migrations/meta/0009_snapshot.json b/src/lib/db/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..88ba397 --- /dev/null +++ b/src/lib/db/migrations/meta/0009_snapshot.json @@ -0,0 +1,1058 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "fc9cf8d7-8d7f-44d3-80e5-80f2f705c22c", + "prevId": "8c9be6b6-e2ab-486c-bc14-3ab2d96a7525", + "tables": { + "attachments": { + "name": "attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_id": { + "name": "content_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_inline": { + "name": "is_inline", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "attachments_message_id_messages_id_fk": { + "name": "attachments_message_id_messages_id_fk", + "tableFrom": "attachments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "canned_responses": { + "name": "canned_responses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customers": { + "name": "customers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "portal_token": { + "name": "portal_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "customers_telegram_chat_id_unique": { + "name": "customers_telegram_chat_id_unique", + "columns": [ + "telegram_chat_id" + ], + "isUnique": true + }, + "customers_portal_token_unique": { + "name": "customers_portal_token_unique", + "columns": [ + "portal_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ldap_config": { + "name": "ldap_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 389 + }, + "use_tls": { + "name": "use_tls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bind_dn": { + "name": "bind_dn", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bind_password_enc": { + "name": "bind_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_dn": { + "name": "base_dn", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_filter": { + "name": "user_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'(mail={{email}})'" + }, + "list_filter": { + "name": "list_filter", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'(objectClass=person)'" + }, + "default_role": { + "name": "default_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mailbox_config": { + "name": "mailbox_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "imap_host": { + "name": "imap_host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imap_port": { + "name": "imap_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 993 + }, + "smtp_host": { + "name": "smtp_host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "smtp_port": { + "name": "smtp_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 587 + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allow_insecure_tls": { + "name": "allow_insecure_tls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ticket_id": { + "name": "ticket_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_type": { + "name": "author_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_ticket_id_tickets_id_fk": { + "name": "messages_ticket_id_tickets_id_fk", + "tableFrom": "messages", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "columns": [ + "endpoint" + ], + "isUnique": true + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'accent'" + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "tags_name_unique": { + "name": "tags_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "telegram_config": { + "name": "telegram_config", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_token_enc": { + "name": "bot_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bot_username": { + "name": "bot_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ticket_tags": { + "name": "ticket_tags", + "columns": { + "ticket_id": { + "name": "ticket_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "ticket_tags_ticket_id_tickets_id_fk": { + "name": "ticket_tags_ticket_id_tickets_id_fk", + "tableFrom": "ticket_tags", + "tableTo": "tickets", + "columnsFrom": [ + "ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ticket_tags_tag_id_tags_id_fk": { + "name": "ticket_tags_tag_id_tags_id_fk", + "tableFrom": "ticket_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ticket_tags_ticket_id_tag_id_pk": { + "columns": [ + "ticket_id", + "tag_id" + ], + "name": "ticket_tags_ticket_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tickets": { + "name": "tickets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'normal'" + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "tickets_customer_id_customers_id_fk": { + "name": "tickets_customer_id_customers_id_fk", + "tableFrom": "tickets", + "tableTo": "customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tickets_assignee_id_users_id_fk": { + "name": "tickets_assignee_id_users_id_fk", + "tableFrom": "tickets", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "auth_source": { + "name": "auth_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'local'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "widget_sites": { + "name": "widget_sites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_key": { + "name": "site_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_origin": { + "name": "allowed_origin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch('subsec') * 1000)" + } + }, + "indexes": { + "widget_sites_site_key_unique": { + "name": "widget_sites_site_key_unique", + "columns": [ + "site_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json index 05ad199..56f7e8d 100644 --- a/src/lib/db/migrations/meta/_journal.json +++ b/src/lib/db/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1787126651618, "tag": "0008_quick_ultragirl", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1787129904542, + "tag": "0009_little_natasha_romanoff", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 074215d..7c363ca 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -182,11 +182,16 @@ export const attachments = sqliteTable("attachments", { sizeBytes: integer("size_bytes").notNull(), // Relative path under DATA_DIR/attachments/ — see src/lib/attachments/storage.ts. storageKey: text("storage_key").notNull(), - // The email's Content-ID header (no angle brackets) for an inline image — - // only set for attachments extracted from an HTML email body, used to - // rewrite `cid:` references in messages.bodyHtml to this attachment's - // serving URL. Null for regular (non-inline) attachments. + // The email's Content-ID header (no angle brackets) for a cid:-referenced + // inline image — used to rewrite `cid:` references in messages.bodyHtml + // to this attachment's serving URL. Null for regular attachments *and* + // for inline images that came from a data: URI instead (see isInline). contentId: text("content_id"), + // True for any attachment whose file is already shown inside the + // message's bodyHtml (a cid: image, or one extracted from a data: URI — + // see lib/mail/imap.ts) — the UI uses this to skip showing it a second + // time as a separate chip below the already-rendered body. + isInline: integer("is_inline", { mode: "boolean" }).notNull().default(false), createdAt: timestamps.createdAt, }); diff --git a/src/lib/mail/imap.test.ts b/src/lib/mail/imap.test.ts new file mode 100644 index 0000000..992fc47 --- /dev/null +++ b/src/lib/mail/imap.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { extractDataUriImages } from "./imap"; + +const TINY_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +describe("extractDataUriImages", () => { + it("extracts a data:image src into a real attachment and rewrites the HTML to reference it", async () => { + const html = `

see

shot`; + const { html: out, attachments } = await extractDataUriImages(html); + + expect(attachments).toHaveLength(1); + expect(attachments[0].mimeType).toBe("image/png"); + expect(attachments[0].isInline).toBe(true); + expect(attachments[0].contentId).toBeNull(); + expect(out).toContain(`/api/attachments/${attachments[0].id}`); + expect(out).not.toContain("base64"); + }); + + it("dedupes the same image embedded twice into a single attachment", async () => { + const src = `data:image/png;base64,${TINY_PNG_B64}`; + const html = `

middle

`; + const { html: out, attachments } = await extractDataUriImages(html); + + expect(attachments).toHaveLength(1); + const occurrences = out.split(`/api/attachments/${attachments[0].id}`).length - 1; + expect(occurrences).toBe(2); + }); + + it("leaves HTML with no data: images untouched", async () => { + const html = '

hi

'; + const { html: out, attachments } = await extractDataUriImages(html); + expect(attachments).toHaveLength(0); + expect(out).toBe(html); + }); +}); diff --git a/src/lib/mail/imap.ts b/src/lib/mail/imap.ts index 1b3527b..2931064 100644 --- a/src/lib/mail/imap.ts +++ b/src/lib/mail/imap.ts @@ -56,21 +56,91 @@ function extractBody(parsed: ParsedMail): string { return "(пустое письмо)"; } +interface ExtractedAttachment { + id: string; + filename: string; + mimeType: string; + sizeBytes: number; + storageKey: string; + contentId: string | null; + isInline: boolean; +} + +const DATA_URI_IMG_RE = /src=(["'])(data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+)\1/g; + /** - * Sanitizes the HTML part for display and rewrites `cid:` inline-image - * references to the URL the matching attachment will be served from — - * `attachmentIdByCid` must already reflect the IDs the attachments are about - * to be inserted with (see handleRawMessage, which generates them upfront - * for exactly this reason). Data-URI images need no rewriting; the browser - * renders those natively once real HTML (not flattened text) reaches the UI. + * Pulls every `data:image/...;base64,...` embedded directly in an + * out into a real attachment file, replacing it in the HTML with that + * attachment's serving URL. Two things this fixes: (1) a data: URI can be + * hundreds of KB, sitting in the messages table's body_html column instead + * of the filesystem; (2) browsers (Chrome specifically) block navigating a + * tab to a data: URL, so a
around the + * image — the whole point of the click-to-zoom feature — silently does + * nothing. A real /api/attachments/{id} URL has neither problem. Identical + * data URIs (the same image embedded twice) are deduped to one attachment. */ -function extractHtmlBody(parsed: ParsedMail, attachmentIdByCid: Map): string | null { - if (typeof parsed.html !== "string") return null; - let html = parsed.html; +export async function extractDataUriImages(html: string): Promise<{ html: string; attachments: ExtractedAttachment[] }> { + const attachmentIdByDataUri = new Map(); + const attachments: ExtractedAttachment[] = []; + + for (const match of html.matchAll(DATA_URI_IMG_RE)) { + const dataUri = match[2]; + if (attachmentIdByDataUri.has(dataUri)) continue; + + const [meta, base64Payload] = dataUri.slice("data:".length).split(";base64,"); + if (!base64Payload) continue; + const buffer = Buffer.from(base64Payload, "base64"); + if (buffer.length === 0) continue; + + try { + const { storageKey, sizeBytes } = await saveAttachment(buffer); + const attachmentId = crypto.randomUUID(); + const extension = meta.split("/")[1]?.split("+")[0] || "png"; + attachments.push({ + id: attachmentId, + filename: `image-${attachments.length + 1}.${extension}`, + mimeType: meta || "image/png", + sizeBytes, + storageKey, + contentId: null, + isInline: true, + }); + attachmentIdByDataUri.set(dataUri, attachmentId); + } catch (err) { + console.error("[mail] failed to save data-URI image:", err); + } + } + + let rewritten = html; + for (const [dataUri, attachmentId] of attachmentIdByDataUri) { + rewritten = rewritten.split(dataUri).join(`/api/attachments/${attachmentId}`); + } + return { html: rewritten, attachments }; +} + +/** + * Sanitizes the HTML part for display and rewrites `cid:`/data-URI inline + * images to the URL the matching attachment will be served from — + * `attachmentIdByCid` must already reflect the IDs the cid attachments are + * about to be inserted with (see handleRawMessage, which generates them + * upfront for exactly this reason). Data-URI images are extracted into + * their own attachments here instead (see extractDataUriImages) — the + * caller must fold the returned attachments into the same insert. + */ +async function extractHtmlBody( + parsed: ParsedMail, + attachmentIdByCid: Map, +): Promise<{ html: string | null; attachments: ExtractedAttachment[] }> { + if (typeof parsed.html !== "string") return { html: null, attachments: [] }; + + const { html: withDataUrisExtracted, attachments } = await extractDataUriImages(parsed.html); + + let html = withDataUrisExtracted; for (const [cid, attachmentId] of attachmentIdByCid) { html = html.split(`cid:${cid}`).join(`/api/attachments/${attachmentId}`); } - return sanitizeEmailHtml(html); + + return { html: sanitizeEmailHtml(html), attachments }; } async function handleRawMessage(source: Buffer): Promise { @@ -101,21 +171,24 @@ async function handleRawMessage(source: Buffer): Promise { sizeBytes, storageKey, contentId: part.cid ?? null, + isInline: Boolean(part.cid), }); } catch (err) { console.error("[mail] failed to save attachment:", err); } } + const { html: bodyHtml, attachments: dataUriAttachments } = await extractHtmlBody(parsed, attachmentIdByCid); + await recordEmailInboundMessage({ fromEmail: from.address, fromName: from.name || from.address, subject: parsed.subject ?? "", body: extractBody(parsed), - bodyHtml: extractHtmlBody(parsed, attachmentIdByCid), + bodyHtml, messageId: parsed.messageId ?? null, referencedMessageIds: referencedIds, - attachments: attachmentInputs, + attachments: [...attachmentInputs, ...dataUriAttachments], }); } diff --git a/src/lib/mail/sanitize-html.test.ts b/src/lib/mail/sanitize-html.test.ts index d9b1817..b3b178c 100644 --- a/src/lib/mail/sanitize-html.test.ts +++ b/src/lib/mail/sanitize-html.test.ts @@ -50,8 +50,17 @@ describe("sanitizeEmailHtml", () => { }); it("wraps a bare image in a link to its own full-size src, opened in a new tab", () => { + // A non-data: src, since Chrome refuses to navigate a tab to a data: + // URL — by the time sanitizeEmailHtml runs, imap.ts has already + // extracted data:image srcs into real /api/attachments/... URLs. + const out = sanitizeEmailHtml('shot'); + expect(out).toMatch(/]*target="_blank"[^>]*>]*><\/a>/); + }); + + it("leaves a residual data: URI image unwrapped rather than linking somewhere Chrome won't navigate", () => { const out = sanitizeEmailHtml('shot'); - expect(out).toMatch(/]*target="_blank"[^>]*>]*><\/a>/); + expect(out).not.toContain(" { diff --git a/src/lib/mail/sanitize-html.ts b/src/lib/mail/sanitize-html.ts index 5e67b04..a97b86e 100644 --- a/src/lib/mail/sanitize-html.ts +++ b/src/lib/mail/sanitize-html.ts @@ -68,6 +68,14 @@ export function sanitizeEmailHtml(html: string): string { * Runs after sanitizeHtml, on already-sanitized output — the href is just * the img's own already-scheme-validated src, so this can't reintroduce * anything sanitizeHtml would have stripped. + * + * data: URIs are skipped here — imap.ts extracts every data:image src into + * a real attachment (and a real /api/attachments/... URL) before this ever + * runs, specifically because Chrome silently refuses to navigate a tab to a + * data: URL, so a link built from one would look clickable but do nothing. + * Anything still starting with "data:" at this point is a residual case + * that extraction didn't catch — better left unwrapped (a plain image) than + * wrapped in a link that appears to work but doesn't. */ function wrapBareImagesInLinks(html: string): string { const $ = cheerio.load(html, null, false); @@ -75,7 +83,7 @@ function wrapBareImagesInLinks(html: string): string { const $img = $(el); if ($img.closest("a").length > 0) return; const src = $img.attr("src"); - if (!src) return; + if (!src || src.startsWith("data:")) return; // Built via .attr(), not string-interpolated HTML — src is // attacker-influenced (an already scheme-validated but otherwise // arbitrary data:/http(s) URL), and interpolating it into an HTML diff --git a/src/lib/tickets/service.ts b/src/lib/tickets/service.ts index 4afe21e..2b343cf 100644 --- a/src/lib/tickets/service.ts +++ b/src/lib/tickets/service.ts @@ -29,6 +29,7 @@ type AttachmentInput = { sizeBytes: number; storageKey: string; contentId?: string | null; + isInline?: boolean; }; function toTicketDTO(ticket: typeof tickets.$inferSelect, customerName: string, tags: TagDTO[]): TicketDTO { @@ -109,7 +110,7 @@ async function getAttachmentsForMessages(messageIds: string[]): Promise