diff --git a/frontend/apps/widget/src/components/ChatMessages.vue b/frontend/apps/widget/src/components/ChatMessages.vue index 9da968c8..feb9a9dd 100644 --- a/frontend/apps/widget/src/components/ChatMessages.vue +++ b/frontend/apps/widget/src/components/ChatMessages.vue @@ -20,11 +20,12 @@ /> +
@@ -257,6 +259,7 @@ watch( currentConversationUUID.value = newUUID unreadMessages.value = 0 hasUserScrolled.value = false + nextTick(scrollToBottom) if (widgetStore.isOpen && !chatStore.isLoadingConversation) { chatStore.updateCurrentConversationLastSeen() } diff --git a/frontend/shared-ui/composables/useStickyScroll.js b/frontend/shared-ui/composables/useStickyScroll.js index d389ab75..def53733 100644 --- a/frontend/shared-ui/composables/useStickyScroll.js +++ b/frontend/shared-ui/composables/useStickyScroll.js @@ -42,7 +42,7 @@ export function useStickyScroll (scrollEl, contentEl, options = {}) { const onContentResize = () => { if (skipAutoScroll() || hasUserScrolled.value) return - requestAnimationFrame(scrollToBottom) + scrollToBottom() } onMounted(() => { diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index ee90c34d..965fc605 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -1450,11 +1450,13 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio // DeleteConversation deletes a conversation. func (m *Manager) DeleteConversation(uuid string) error { - m.lo.Info("deleting conversation", "uuid", uuid) - if _, err := m.q.DeleteConversation.Exec(uuid); err != nil { - m.lo.Error("error deleting conversation", "error", err) + res, err := m.q.DeleteConversation.Exec(uuid) + if err != nil { + m.lo.Error("error deleting conversation", "uuid", uuid, "error", err) return envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } + rows, _ := res.RowsAffected() + m.lo.Info("deleted conversation", "uuid", uuid, "rows_affected", rows) return nil } diff --git a/internal/conversation/message.go b/internal/conversation/message.go index b2fe429e..b4e60a4b 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -756,6 +756,7 @@ func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Mess Type: umodels.UserTypeContact, } if err := m.userStore.CreateContact(&user); err != nil { + m.lo.Error("error creating contact for incoming message", "message_source_id", in.SourceID.String, "email", in.Contact.Email.String, "error", err) return models.Message{}, fmt.Errorf("creating contact: %w", err) } senderID = user.ID @@ -767,6 +768,7 @@ func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Mess if conversationID == 0 { conversationID, conversationUUID, isNewConversation, err = m.findOrCreateConversation(in) if err != nil { + m.lo.Error("error finding or creating conversation for incoming message", "message_source_id", in.SourceID.String, "error", err) return models.Message{}, err } } @@ -795,9 +797,15 @@ func (m *Manager) ProcessIncomingMessage(in models.IncomingMessage) (models.Mess return models.Message{}, fmt.Errorf("uploading message attachments: %w", upErr) } - // Insert message. + // Insert message. On failure, delete the conversation if it was just created for this message. if err = m.InsertMessage(&msg); err != nil { - return models.Message{}, err + m.lo.Error("error inserting incoming message", "message_source_id", in.SourceID.String, "conversation_uuid", conversationUUID, "is_new", isNewConversation, "error", err) + if isNewConversation && conversationUUID != "" { + if delErr := m.DeleteConversation(conversationUUID); delErr != nil { + return models.Message{}, fmt.Errorf("deleting conversation after message insert failure: %w", delErr) + } + } + return models.Message{}, fmt.Errorf("inserting message: %w", err) } // When a customer replies to a continuity emailsync the message to their live chat widget via WebSocket. @@ -1130,7 +1138,7 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) error { []byte("{}"), /** meta **/ ) if err != nil { - m.lo.Error("failed to upload attachment", "name", attachment.Name, "error", err) + m.lo.Error("failed to upload attachment", "name", attachment.Name, "content_type", attachment.ContentType, "size", attachment.Size, "content_id", contentID, "disposition", attachment.Disposition, "conversation_uuid", message.ConversationUUID, "message_source_id", message.SourceID.String, "error", err) return fmt.Errorf("failed to upload media %s: %w", attachment.Name, err) } diff --git a/internal/inbox/channel/email/imap.go b/internal/inbox/channel/email/imap.go index 43808ff9..3cfd725a 100644 --- a/internal/inbox/channel/email/imap.go +++ b/internal/inbox/channel/email/imap.go @@ -532,6 +532,11 @@ func (e *Email) processFullMessage(item imapclient.FetchItemDataBodySection, inc }) } + incomingMsg.Content = stringutil.SanitizeUTF8(incomingMsg.Content) + incomingMsg.Subject = stringutil.SanitizeUTF8(incomingMsg.Subject) + incomingMsg.Contact.FirstName = stringutil.SanitizeUTF8(incomingMsg.Contact.FirstName) + incomingMsg.Contact.LastName = stringutil.SanitizeUTF8(incomingMsg.Contact.LastName) + e.lo.Debug("enqueuing incoming email message", "message_id", incomingMsg.SourceID.String, "attachments", len(envelope.Attachments), "inline_attachments", len(envelope.Inlines)) diff --git a/internal/media/media.go b/internal/media/media.go index 84719e52..d46169af 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -122,13 +122,13 @@ func (m *Manager) Upload(fileName, contentType string, content io.ReadSeeker) (s // Detect content type and override if needed. contentType, err := m.detectContentType(contentType, content) if err != nil { - m.lo.Error("error detecting content type", "error", err) + m.lo.Error("error detecting content type", "error", err, "file_name", fileName, "content_type", contentType, "store", m.store.Name()) return "", "", err } fName, err := m.store.Put(fileName, contentType, content) if err != nil { - m.lo.Error("error uploading media", "error", err) + m.lo.Error("error uploading media to store", "error", err, "file_name", fileName, "content_type", contentType, "store", m.store.Name()) return "", "", envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.errorUploadingFile"), nil) } return fName, contentType, nil @@ -138,7 +138,7 @@ func (m *Manager) Upload(fileName, contentType string, content io.ReadSeeker) (s func (m *Manager) Insert(disposition null.String, fileName, contentType, contentID string, modelType null.String, uuid string, modelID null.Int, fileSize int, meta []byte) (models.Media, error) { var id int if err := m.queries.Insert.QueryRow(m.store.Name(), fileName, contentType, fileSize, meta, modelID, modelType, disposition, contentID, uuid).Scan(&id); err != nil { - m.lo.Error("error inserting media", "error", err) + m.lo.Error("error inserting media", "error", err, "file_name", fileName, "content_type", contentType, "store", m.store.Name()) return models.Media{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } return m.Get(id, "") diff --git a/internal/media/stores/localfs/fs.go b/internal/media/stores/localfs/fs.go index f95eaf28..42554976 100644 --- a/internal/media/stores/localfs/fs.go +++ b/internal/media/stores/localfs/fs.go @@ -42,13 +42,13 @@ func (c *Client) Put(filename string, cType string, src io.ReadSeeker) (string, dir := getDir(c.opts.UploadPath) o, err := os.OpenFile(filepath.Join(dir, filename), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664) if err != nil { - return "", err + return "", fmt.Errorf("opening file for write %q: %w", filepath.Join(dir, filename), err) } out = o defer out.Close() - if _, err := io.Copy(out, src); err != nil { - return "", err + if n, err := io.Copy(out, src); err != nil { + return "", fmt.Errorf("writing file %q after %d bytes: %w", filepath.Join(dir, filename), n, err) } return filename, nil } diff --git a/internal/media/stores/s3/s3.go b/internal/media/stores/s3/s3.go index e19d5953..00579b19 100644 --- a/internal/media/stores/s3/s3.go +++ b/internal/media/stores/s3/s3.go @@ -80,7 +80,7 @@ func (c *Client) Put(name string, cType string, file io.ReadSeeker) (string, err } if _, err := c.s3.FilePut(p); err != nil { - return "", err + return "", fmt.Errorf("s3 put bucket=%q key=%q content_type=%q: %w", c.opts.Bucket, p.ObjectKey, cType, err) } return name, nil diff --git a/internal/stringutil/stringutil.go b/internal/stringutil/stringutil.go index 86b6600e..349901f1 100644 --- a/internal/stringutil/stringutil.go +++ b/internal/stringutil/stringutil.go @@ -25,6 +25,15 @@ var ( regexpConvUUID = regexp.MustCompile(`(?i)\+conv-[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}@`) ) +// SanitizeUTF8 removes NUL bytes and replaces invalid UTF-8 byte sequences with the Unicode replacement character. +func SanitizeUTF8(s string) string { + if s == "" { + return s + } + s = strings.ReplaceAll(s, "\x00", "") + return strings.ToValidUTF8(s, "�") +} + // HTML2Text converts HTML to text. func HTML2Text(html string) string { out, err := html2text.FromString(html, html2text.Options{TextOnly: true}) diff --git a/internal/stringutil/stringutil_test.go b/internal/stringutil/stringutil_test.go index 59be477b..bcd6100b 100644 --- a/internal/stringutil/stringutil_test.go +++ b/internal/stringutil/stringutil_test.go @@ -175,7 +175,6 @@ func TestExtractConvUUID(t *testing.T) { } } - func TestExtractReferenceNumber(t *testing.T) { tests := []struct { name string @@ -233,3 +232,41 @@ func TestExtractReferenceNumber(t *testing.T) { }) } } + +func TestSanitizeUTF8(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"empty", "", ""}, + {"plain ascii unchanged", "Hello, world!", "Hello, world!"}, + {"valid copyright unchanged", "© 2026", "© 2026"}, + {"orphan 0xa9 replaced", "\xa9 2026 Upstox", "� 2026 Upstox"}, + {"nul stripped", "a\x00b", "ab"}, + {"chinese unchanged", "你好世界", "你好世界"}, + {"devanagari unchanged", "नमस्ते", "नमस्ते"}, + {"arabic unchanged", "مرحبا", "مرحبا"}, + {"emoji unchanged", "ok 😀👍", "ok 😀👍"}, + {"accented latin unchanged", "café résumé", "café résumé"}, + {"run of invalid bytes collapses to one replacement", "x\xa9\xa9y", "x�y"}, + {"truncated 3-byte char replaced", "\xe4\xbd", "�"}, + {"lead byte at end replaced", "abc\xc3", "abc�"}, + {"overlong encoding replaced", "x\xc0\x80y", "x�y"}, + {"cp1252 smart quotes replaced", "\x93hi\x94", "�hi�"}, + {"multiple embedded nuls stripped", "a\x00\x00b\x00c", "abc"}, + {"nul and invalid byte combined", "a\x00\xa9b", "a�b"}, + {"valid multibyte preserved around invalid byte", "a你\xa9好b", "a你�好b"}, + {"bom preserved", "\ufeffhi", "\ufeffhi"}, + {"existing replacement char preserved", "a�b", "a�b"}, + {"crlf and tab preserved", "l1\r\nl2\t", "l1\r\nl2\t"}, + {"paired continuation kept, orphan replaced", "é\xa9", "é�"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SanitizeUTF8(tt.input); got != tt.expected { + t.Errorf("SanitizeUTF8(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +}