From 7fee393cce8a5924287d19db1521afcaeb6644e9 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 20 Oct 2026 00:58:33 +0530 Subject: [PATCH 01/13] fix issues found during release testing - Save recipient edits and sender choices with reply drafts. - Keep CC and BCC open when an address is cleared. - Allow SMTP without credentials when authentication is off. - Use the workspace timezone for reports and campaign dates. - Show CSAT responses without reloading the conversation. - Block access to attachments from deleted messages. - Accept conditional email templates and catch missing or repeated replies. - Explain why choosing another default email template failed. - Reload stale routes and keep widget image fallbacks working. - Keep the launcher usable by keyboard and reset host button styles. --- cmd/csat.go | 5 +- cmd/report.go | 10 +- .../main/src/composables/useDraftManager.js | 24 +++- .../src/composables/useDraftManager.test.js | 117 +++++++++++++++++ .../features/admin/inbox/WidgetCampaigns.vue | 19 ++- .../src/features/admin/inbox/formSchema.js | 11 +- .../features/admin/inbox/formSchema.test.js | 22 ++++ .../features/admin/notification/formSchema.js | 15 ++- .../admin/notification/formSchema.test.js | 22 ++++ .../src/features/conversation/ReplyBox.vue | 62 ++++----- frontend/apps/main/src/router/index.js | 17 +++ .../src/components/MessageAttachment.vue | 15 +-- .../src/composables/useReplyPreviews.js | 2 + i18n/en-US.json | 3 +- internal/conversation/conversation.go | 19 +++ internal/conversation/conversation_test.go | 83 ++++++++++++ internal/conversation/queries.sql | 7 + internal/media/media_test.go | 62 +++++++++ internal/media/queries.sql | 9 +- internal/report/queries.sql | 20 +-- internal/report/report.go | 21 +-- internal/report/report_test.go | 118 +++++++++++++++++ internal/template/render.go | 54 +++++++- internal/template/template.go | 24 +++- internal/template/template_test.go | 122 ++++++++++++++++++ static/widget.js | 14 +- 26 files changed, 798 insertions(+), 99 deletions(-) create mode 100644 frontend/apps/main/src/composables/useDraftManager.test.js create mode 100644 internal/report/report_test.go diff --git a/cmd/csat.go b/cmd/csat.go index 0171a0cf..d2c76992 100644 --- a/cmd/csat.go +++ b/cmd/csat.go @@ -86,7 +86,7 @@ func handleUpdateCSATResponse(r *fastglue.Request) error { }) } - if err := app.csat.UpdateResponse(uuid, rating, feedback, metaJSON); err != nil { + if err := app.conversation.UpdateCSATResponse(uuid, rating, feedback, metaJSON); err != nil { // Surface only known, user-safe messages; never leak raw driver errors // (e.g. a malformed UUID triggers a Postgres syntax error string). msg := app.i18n.T("globals.messages.somethingWentWrong") @@ -161,8 +161,7 @@ func handleSubmitCSATResponse(r *fastglue.Request) error { req.Feedback = truncateFeedback(req.Feedback) - // Update CSAT response - if err := app.csat.UpdateResponse(uuid, req.Rating, req.Feedback, nil); err != nil { + if err := app.conversation.UpdateCSATResponse(uuid, req.Rating, req.Feedback, nil /** meta **/); err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/report.go b/cmd/report.go index f0dec2ef..91fda470 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -24,7 +24,7 @@ func handleOverviewCharts(r *fastglue.Request) error { app = r.Context.(*App) days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) ) - charts, err := app.report.GetOverviewChart(days) + charts, err := app.report.GetOverviewChart(days, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } @@ -37,7 +37,7 @@ func handleOverviewSLA(r *fastglue.Request) error { app = r.Context.(*App) days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) ) - sla, err := app.report.GetOverviewSLA(days) + sla, err := app.report.GetOverviewSLA(days, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } @@ -50,7 +50,7 @@ func handleOverviewCSAT(r *fastglue.Request) error { app = r.Context.(*App) days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) ) - csat, err := app.report.GetOverviewCSAT(days) + csat, err := app.report.GetOverviewCSAT(days, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } @@ -63,7 +63,7 @@ func handleOverviewMessageVolume(r *fastglue.Request) error { app = r.Context.(*App) days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) ) - volume, err := app.report.GetOverviewMessageVolume(days) + volume, err := app.report.GetOverviewMessageVolume(days, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } @@ -76,7 +76,7 @@ func handleOverviewTagDistribution(r *fastglue.Request) error { app = r.Context.(*App) days, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("days"))) ) - tags, err := app.report.GetOverviewTagDistribution(days) + tags, err := app.report.GetOverviewTagDistribution(days, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/frontend/apps/main/src/composables/useDraftManager.js b/frontend/apps/main/src/composables/useDraftManager.js index 310baab1..6e505976 100644 --- a/frontend/apps/main/src/composables/useDraftManager.js +++ b/frontend/apps/main/src/composables/useDraftManager.js @@ -23,13 +23,21 @@ const validateAttachments = (attachments) => { ) } +const validateRecipients = (recipients) => + Object.fromEntries( + ['to', 'cc', 'bcc', 'send_from'] + .filter(key => typeof recipients?.[key] === 'string') + .map(key => [key, recipients[key]]) + ) + const isDraftEmpty = (draft) => { const content = draft?.content || '' const hasText = getTextFromHTML(content).length > 0 const hasInlineImage = / 0 const hasMacroActions = draft?.meta?.macro_actions?.length > 0 - return !hasText && !hasInlineImage && !hasAttachments && !hasMacroActions + const hasRecipients = Object.keys(validateRecipients(draft?.meta?.recipients)).length > 0 + return !hasText && !hasInlineImage && !hasAttachments && !hasMacroActions && !hasRecipients } const draftKey = (uuid, type) => `${uuid}::${type}` @@ -38,6 +46,7 @@ const metaSignature = (meta) => JSON.stringify({ macro_id: meta?.macro_id || 0, macro_actions: meta?.macro_actions || [], + recipients: validateRecipients(meta?.recipients), attachments: (meta?.attachments || []).map(a => a.uuid) }) @@ -54,13 +63,17 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles = const loadedAttachments = ref([]) const loadedMacroActions = ref([]) const loadedMacroID = ref(0) + const recipients = ref({}) // Live-key guard: the editor is transiently empty during open/switch and must not clobber a stored draft. const loadedKey = ref(null) const currentKey = () => draftKey(conversationUUID.value, messageType.value) - const buildDraft = () => { + const buildDraft = (type) => { const meta = {} + if (type === 'reply' && Object.keys(recipients.value).length > 0) { + meta.recipients = { ...recipients.value } + } const macro = conversationStore.getMacro(MACRO_CONTEXT.REPLY) const macroActions = macro?.actions || [] if (macro?.id > 0) meta.macro_id = macro.id @@ -85,6 +98,7 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles = loadedAttachments.value = validateAttachments(draft?.meta?.attachments) loadedMacroActions.value = validateMacroActions(draft?.meta?.macro_actions) loadedMacroID.value = Number(draft?.meta?.macro_id) > 0 ? Number(draft.meta.macro_id) : 0 + recipients.value = validateRecipients(draft?.meta?.recipients) } const load = async (uuid, type) => { @@ -109,7 +123,7 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles = // Update the store synchronously; server sync runs in the background, never awaited. const save = (uuid, type) => { - const draft = buildDraft() + const draft = buildDraft(type) if (sameDraft(draft, conversationStore.getDraft(uuid, type))) return if (isDraftEmpty(draft)) { conversationStore.removeDraft(uuid, type) @@ -134,6 +148,7 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles = const watchSources = [ htmlContent, textContent, + recipients, () => conversationStore.macros[MACRO_CONTEXT.REPLY] ] if (uploadedFiles) watchSources.push(uploadedFiles) @@ -174,6 +189,7 @@ export function useDraftManager (conversationUUID, messageType, uploadedFiles = clearDraft, loadedAttachments, loadedMacroActions, - loadedMacroID + loadedMacroID, + recipients } } diff --git a/frontend/apps/main/src/composables/useDraftManager.test.js b/frontend/apps/main/src/composables/useDraftManager.test.js new file mode 100644 index 00000000..e8e4d0a8 --- /dev/null +++ b/frontend/apps/main/src/composables/useDraftManager.test.js @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope, nextTick, ref } from 'vue' +import { useDraftManager } from './useDraftManager' + +const { drafts, api, store } = vi.hoisted(() => { + const drafts = new Map() + return { + drafts, + api: { saveDraft: vi.fn().mockResolvedValue({}), deleteDraft: vi.fn().mockResolvedValue({}) }, + store: { + macros: {}, + draftsReady: Promise.resolve(), + getMacro: () => null, + getDraft: (uuid, type) => drafts.get(`${uuid}:${type}`), + setDraft: (uuid, type, draft) => drafts.set(`${uuid}:${type}`, draft), + removeDraft: (uuid, type) => drafts.delete(`${uuid}:${type}`) + } + } +}) +vi.mock('@main/stores/conversation', () => ({ useConversationStore: () => store })) +vi.mock('@main/api', () => ({ default: api })) + +let scopes +const settle = async () => { + await nextTick() + await vi.advanceTimersByTimeAsync(600) +} +const openDraft = () => { + const scope = effectScope() + scopes.push(scope) + const uuid = ref('conversation-a') + const type = ref('reply') + const draft = scope.run(() => useDraftManager(uuid, type)) + return { ...draft, uuid, type } +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + drafts.clear() + scopes = [] +}) +afterEach(() => { + scopes.forEach(scope => scope.stop()) + vi.useRealTimers() +}) + +describe('reply draft recipients', () => { + it('restores recipient edits with the body after remount', async () => { + const draft = openDraft() + await settle() + draft.htmlContent.value = '

Keep this reply.

' + draft.recipients.value = { to: 'other@example.com', cc: '', bcc: 'audit@example.com', send_from: 'alias@example.com' } + await settle() + const restored = openDraft() + await settle() + expect(restored.htmlContent.value).toBe('

Keep this reply.

') + expect(restored.recipients.value).toEqual(draft.recipients.value) + expect(api.saveDraft).toHaveBeenCalledTimes(1) + }) + + it('preserves an explicitly removed recipient without message text', async () => { + const draft = openDraft() + await settle() + draft.recipients.value = { cc: '' } + await settle() + expect(drafts.get('conversation-a:reply').meta.recipients).toEqual({ cc: '' }) + expect(api.deleteDraft).not.toHaveBeenCalled() + }) + + it('isolates recipients across conversations and private notes', async () => { + const draft = openDraft() + await settle() + draft.recipients.value = { bcc: 'audit@example.com' } + draft.type.value = 'private_note' + await settle() + expect(draft.recipients.value).toEqual({}) + draft.htmlContent.value = '

Private note

' + await settle() + expect(drafts.get('conversation-a:private_note').meta).not.toHaveProperty('recipients') + draft.type.value = 'reply' + await settle() + expect(draft.recipients.value).toEqual({ bcc: 'audit@example.com' }) + draft.uuid.value = 'conversation-b' + await settle() + expect(draft.recipients.value).toEqual({}) + draft.recipients.value = { to: 'second@example.com' } + draft.uuid.value = 'conversation-a' + await settle() + expect(draft.recipients.value).toEqual({ bcc: 'audit@example.com' }) + expect(drafts.get('conversation-b:reply').meta.recipients).toEqual({ to: 'second@example.com' }) + }) + + it('clears recipients after a successful send without resurrecting the draft', async () => { + const draft = openDraft() + await settle() + draft.recipients.value = { to: '', cc: 'copy@example.com' } + await settle() + draft.clearDraft() + await settle() + expect(draft.recipients.value).toEqual({}) + expect(drafts.size).toBe(0) + expect(api.deleteDraft).toHaveBeenCalledTimes(1) + }) + + it('loads older drafts and ignores malformed recipient metadata', async () => { + drafts.set('conversation-a:reply', { content: '

Older draft

', meta: {} }) + const draft = openDraft() + await settle() + expect(draft.recipients.value).toEqual({}) + drafts.set('conversation-b:reply', { content: '

Reply

', meta: { recipients: { to: [], cc: '', bcc: null, extra: 'ignored' } } }) + draft.uuid.value = 'conversation-b' + await settle() + expect(draft.recipients.value).toEqual({ cc: '' }) + }) +}) diff --git a/frontend/apps/main/src/features/admin/inbox/WidgetCampaigns.vue b/frontend/apps/main/src/features/admin/inbox/WidgetCampaigns.vue index 85768e5b..ffb0e9f7 100644 --- a/frontend/apps/main/src/features/admin/inbox/WidgetCampaigns.vue +++ b/frontend/apps/main/src/features/admin/inbox/WidgetCampaigns.vue @@ -102,8 +102,8 @@ const businessHours = ref([]) const error = ref('') const pendingDelete = ref(null) const stats = ref([]) -const from = ref(new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)) -const to = ref(new Date().toISOString().slice(0, 10)) +const from = ref('') +const to = ref('') const brandSenderName = computed(() => props.brandName.trim() || t('widget.brandBot')) const campaignEventExample = computed( () => `window.Libredesk.trackEvent(${JSON.stringify(campaign.value?.event || 'pricing_viewed')})` @@ -286,13 +286,24 @@ const refreshStats = async () => { onMounted(async () => { try { - const response = await api.getAllBusinessHours() + const [response, settingsResponse] = await Promise.all([ + api.getAllBusinessHours(), + api.getSettings('general') + ]) const data = response.data.data businessHours.value = data.results || data + const dateFormat = new Intl.DateTimeFormat('en-CA', { + timeZone: settingsResponse.data.data['app.timezone'] || 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit' + }) + from.value = dateFormat.format(new Date(Date.now() - 30 * 86400000)) + to.value = dateFormat.format(new Date()) + await refreshStats() } catch (err) { error.value = handleHTTPError(err).message } - await refreshStats() }) diff --git a/frontend/apps/main/src/features/admin/inbox/formSchema.js b/frontend/apps/main/src/features/admin/inbox/formSchema.js index 99f5fe1e..7e49c666 100644 --- a/frontend/apps/main/src/features/admin/inbox/formSchema.js +++ b/frontend/apps/main/src/features/admin/inbox/formSchema.js @@ -56,8 +56,8 @@ export const createFormSchema = (t) => z.object({ smtp: z.object({ host: z.string().min(1, t('globals.messages.required')), port: z.number().min(1).max(65535), - username: z.string().min(1, t('globals.messages.required')), - password: z.string().min(1, t('globals.messages.required')), + username: z.string().default(''), + password: z.string().default(''), max_conns: z.number().min(1), max_msg_retries: z.number().min(0).max(100), idle_timeout: z.string().min(1, t('globals.messages.required')).refine(isGoDuration, { @@ -70,6 +70,13 @@ export const createFormSchema = (t) => z.object({ tls_skip_verify: z.boolean().optional(), hello_hostname: z.string().optional(), auth_protocol: z.enum(['login', 'cram', 'plain', 'none']) + }).superRefine((smtp, ctx) => { + if (smtp.auth_protocol === 'none') return + for (const field of ['username', 'password']) { + if (!smtp[field]) { + ctx.addIssue({ code: 'custom', path: [field], message: t('globals.messages.required') }) + } + } }) }).superRefine((values, ctx) => { const primary = extractEmailAddress(values.from).toLowerCase() diff --git a/frontend/apps/main/src/features/admin/inbox/formSchema.test.js b/frontend/apps/main/src/features/admin/inbox/formSchema.test.js index fa540b9e..d6c8bda7 100644 --- a/frontend/apps/main/src/features/admin/inbox/formSchema.test.js +++ b/frontend/apps/main/src/features/admin/inbox/formSchema.test.js @@ -248,6 +248,28 @@ describe('Email Inbox Form Schema', () => { } }) + test('unauthenticated SMTP accepts empty and omitted credentials', () => { + for (const credential of ['', undefined]) { + expect(() => schema.parse(withSmtp({ + auth_protocol: 'none', username: credential, password: credential + }))).not.toThrow() + } + }) + + test('authenticated SMTP requires both credentials', () => { + for (const auth_protocol of ['login', 'cram', 'plain']) { + for (const field of ['username', 'password']) { + for (const credential of ['', undefined]) { + const result = schema.safeParse(withSmtp({ auth_protocol, [field]: credential })) + expect(result.success).toBe(false) + expect(result.error.issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: ['smtp', field] }) + ])) + } + } + } + }) + test('smtp hello_hostname optional', () => { expect(() => schema.parse(withSmtp({ hello_hostname: 'desk.example.com' }))).not.toThrow() }) diff --git a/frontend/apps/main/src/features/admin/notification/formSchema.js b/frontend/apps/main/src/features/admin/notification/formSchema.js index bcdc3829..55291a1f 100644 --- a/frontend/apps/main/src/features/admin/notification/formSchema.js +++ b/frontend/apps/main/src/features/admin/notification/formSchema.js @@ -3,9 +3,7 @@ import { isGoDuration } from '@shared-ui/utils/string'; export const createFormSchema = (t) => z.object({ enabled: z.boolean().default(false), - username: z.string().nonempty({ - message: t('globals.messages.required') - }), + username: z.string().default(''), host: z.string().nonempty({ message: t('globals.messages.required') }), @@ -17,9 +15,7 @@ export const createFormSchema = (t) => z.object({ .min(1, { message: t('validation.minmaxNumber', { min: 1, max: 65535 }) }) .max(65535, { message: t('validation.minmaxNumber', { min: 1, max: 65535 }) }) .default(587), - password: z.string().nonempty({ - message: t('globals.messages.required') - }), + password: z.string().default(''), max_conns: z .number({ invalid_type_error: t('globals.messages.mustBeNumber'), @@ -54,4 +50,11 @@ export const createFormSchema = (t) => z.object({ hello_hostname: z.string().optional(), tls_type: z.enum(['none', 'starttls', 'tls']), tls_skip_verify: z.boolean().optional(), +}).superRefine((smtp, ctx) => { + if (smtp.auth_protocol === 'none') return + for (const field of ['username', 'password']) { + if (!smtp[field]) { + ctx.addIssue({ code: 'custom', path: [field], message: t('globals.messages.required') }) + } + } }); diff --git a/frontend/apps/main/src/features/admin/notification/formSchema.test.js b/frontend/apps/main/src/features/admin/notification/formSchema.test.js index 105ee55c..2ef0fe01 100644 --- a/frontend/apps/main/src/features/admin/notification/formSchema.test.js +++ b/frontend/apps/main/src/features/admin/notification/formSchema.test.js @@ -161,6 +161,28 @@ describe('Notification Form Schema', () => { } }) + test('unauthenticated SMTP accepts empty and omitted credentials', () => { + for (const credential of ['', undefined]) { + expect(() => schema.parse({ + ...validForm, auth_protocol: 'none', username: credential, password: credential + })).not.toThrow() + } + }) + + test('authenticated SMTP requires both credentials', () => { + for (const auth_protocol of ['login', 'cram', 'plain']) { + for (const field of ['username', 'password']) { + for (const credential of ['', undefined]) { + const result = schema.safeParse({ ...validForm, auth_protocol, [field]: credential }) + expect(result.success).toBe(false) + expect(result.error.issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: [field] }) + ])) + } + } + } + }) + test('tls_type missing', () => { const { tls_type, ...form } = validForm expect(() => schema.parse(form)).toThrow() diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue index a4624a4d..53fe0269 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBox.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue @@ -332,7 +332,8 @@ const { clearDraft, loadedAttachments, loadedMacroActions, - loadedMacroID + loadedMacroID, + recipients } = useDraftManager(currentConversationUUID, messageType, mediaFiles) // Rest of existing state @@ -340,11 +341,19 @@ const isEditorFullscreen = ref(false) const isMinimized = ref(false) const isSending = ref(false) const isGenerating = ref(false) -const to = ref('') -const cc = ref('') -const bcc = ref('') -const sendFrom = ref('') -const senderPickedFor = ref(null) +const recipientField = (field, defaultValue) => computed({ + get: () => recipients.value[field] ?? defaultValue(), + set: (value) => { recipients.value = { ...recipients.value, [field]: value } } +}) +const to = recipientField('to', () => conversationStore.currentTo?.join(', ') || '') +const cc = recipientField('cc', () => conversationStore.currentCC?.join(', ') || '') +const bcc = recipientField('bcc', () => conversationStore.currentBCC?.join(', ') || '') +const sendFrom = computed({ + get: () => conversationStore.currentFromOptions.includes(recipients.value.send_from) + ? recipients.value.send_from + : conversationStore.currentFrom || '', + set: (value) => { recipients.value = { ...recipients.value, send_from: value } } +}) const showCc = ref(false) const showBcc = ref(false) const emailErrors = ref([]) @@ -720,22 +729,8 @@ watch( const pickSender = (value) => { sendFrom.value = value - senderPickedFor.value = currentConversationUUID.value } -// Keep the agent's own sender pick while it is still allowed, since currentFrom changes whenever a new message arrives. -watch( - [currentConversationUUID, () => conversationStore.currentFrom], - ([uuid, value], [previousUUID] = []) => { - if (uuid !== previousUUID) senderPickedFor.value = null - const picked = - senderPickedFor.value === uuid && - conversationStore.currentFromOptions.includes(sendFrom.value) - if (!picked) sendFrom.value = value || '' - }, - { immediate: true } -) - // Reset first so a loaded draft never inherits the previous conversation's macro (drafts store no message_content). watch( [loadedMacroID, loadedMacroActions], @@ -760,31 +755,22 @@ watch( { deep: true } ) -// Initialize to, cc, and bcc fields with the current conversation's values. watch( - () => conversationStore.currentCC, - (newVal) => { - cc.value = newVal?.join(', ') || '' - showCc.value = cc.value.length > 0 - }, - { deep: true, immediate: true } -) - -watch( - () => conversationStore.currentTo, - (newVal) => { - to.value = newVal?.join(', ') || '' + [isDraftLoading, cc], + ([loading, value], [wasLoading] = []) => { + if (loading) return + if (wasLoading || value.length > 0) showCc.value = value.length > 0 }, { immediate: true } ) watch( - () => conversationStore.currentBCC, - (newVal) => { - bcc.value = newVal?.join(', ') || '' - showBcc.value = bcc.value.length > 0 + [isDraftLoading, bcc], + ([loading, value], [wasLoading] = []) => { + if (loading) return + if (wasLoading || value.length > 0) showBcc.value = value.length > 0 }, - { deep: true, immediate: true } + { immediate: true } ) // Media files and macro state are restored per draft by the draft manager; resetting here would race ahead of the save and drop them. diff --git a/frontend/apps/main/src/router/index.js b/frontend/apps/main/src/router/index.js index 4b28f011..e7d4d2f2 100644 --- a/frontend/apps/main/src/router/index.js +++ b/frontend/apps/main/src/router/index.js @@ -696,6 +696,23 @@ const router = createRouter({ routes: routes }) +const preloadErrors = new WeakSet() +const reloadKey = 'libredesk:preload-reload' + +window.addEventListener('vite:preloadError', (event) => { + preloadErrors.add(event.payload) +}) + +router.onError((error, to) => { + if (!preloadErrors.has(error) || sessionStorage.getItem(reloadKey) === to.fullPath) return + sessionStorage.setItem(reloadKey, to.fullPath) + window.location.assign(to.fullPath) +}) + +router.afterEach((to, from, failure) => { + if (!failure) sessionStorage.removeItem(reloadKey) +}) + router.beforeEach((to, from, next) => { // Cancel in-flight requests. if (to.fullPath !== from.fullPath) { diff --git a/frontend/apps/widget/src/components/MessageAttachment.vue b/frontend/apps/widget/src/components/MessageAttachment.vue index f65de1fe..9f1fd0b0 100644 --- a/frontend/apps/widget/src/components/MessageAttachment.vue +++ b/frontend/apps/widget/src/components/MessageAttachment.vue @@ -5,18 +5,16 @@ :key="attachment.uuid" class="flex items-center cursor-pointer" > -
-
diff --git a/frontend/cypress/e2e/api/templates.cy.js b/frontend/cypress/e2e/api/templates.cy.js index 5f54a2fc..9eb91ebc 100644 --- a/frontend/cypress/e2e/api/templates.cy.js +++ b/frontend/cypress/e2e/api/templates.cy.js @@ -1,6 +1,7 @@ describe('API: templates', () => { const stamp = Date.now() const name = `api-template-${stamp}` + const reply = '{{ template "content" . }}' let templateId before(() => cy.login()) @@ -64,12 +65,22 @@ describe('API: templates', () => { }) }) + it('rejects an outgoing template without the reply block', () => { + cy.api('POST', '/api/v1/templates', { + name: `${name}-noreply`, type: 'email_outgoing', body: '

no reply

' + }, { failOnStatusCode: false }).then(({ status, body }) => { + expect(status).to.eq(400) + expect(body.error_type).to.eq('InputException') + expect(body.message).to.contain('{{ template "content" . }}') + }) + }) + it('creates a template and persists every field', () => { cy.api('POST', '/api/v1/templates', { name, type: 'email_outgoing', subject: 'Api subject', - body: '

api body

', + body: `

api body

${reply}`, is_default: false }).then(({ status, body }) => { expect(status).to.eq(200) @@ -79,7 +90,7 @@ describe('API: templates', () => { expect(body.data.name).to.eq(name) expect(body.data.type).to.eq('email_outgoing') expect(body.data.subject).to.eq('Api subject') - expect(body.data.body).to.eq('

api body

') + expect(body.data.body).to.eq(`

api body

${reply}`) expect(body.data.is_default).to.eq(false) expect(body.data.is_builtin).to.eq(false) }) @@ -113,14 +124,14 @@ describe('API: templates', () => { name: `${name}-renamed`, type: 'email_outgoing', subject: 'Api subject updated', - body: '

updated

', + body: `

updated

${reply}`, is_default: false }).its('status').should('eq', 200) cy.api('GET', `/api/v1/templates/${templateId}`).then(({ body }) => { expect(body.data.name).to.eq(`${name}-renamed`) expect(body.data.subject).to.eq('Api subject updated') - expect(body.data.body).to.eq('

updated

') + expect(body.data.body).to.eq(`

updated

${reply}`) }) }) diff --git a/frontend/cypress/e2e/ui/conversationLifecycle.cy.js b/frontend/cypress/e2e/ui/conversationLifecycle.cy.js index a22d88d4..3139a82b 100644 --- a/frontend/cypress/e2e/ui/conversationLifecycle.cy.js +++ b/frontend/cypress/e2e/ui/conversationLifecycle.cy.js @@ -169,6 +169,9 @@ describe('Conversation lifecycle', () => { beforeEach(() => { cy.viewport(1440, 900) // desktop layout: list, conversation and sidebar all on screen cy.login() + for (const uuid of [conversationUuid, ccConversationUuid]) { + cy.api('DELETE', `/api/v1/conversations/${uuid}/draft?type=reply`) + } }) it('opens the conversation from the inbox list', () => { @@ -346,7 +349,7 @@ describe('Conversation lifecycle', () => { expectSentRecipients(ccConversationUuid, { cc: [], bcc: [] }) }) - it('sends only what each conversation shows after switching between them', () => { + it('preserves edited recipients when switching between conversations', () => { captureSend() openConversation(ccConversationUuid) recipientRow(/^CC$/).find('button[aria-label="Remove CC"]').click() @@ -365,10 +368,10 @@ describe('Conversation lifecycle', () => { cy.contains(ccContactName).click() cy.location('pathname').should('include', ccConversationUuid) recipientRow(/^To$/).find('input').should('have.value', ccCustomerEmail) - recipientRow(/^CC$/).find('input').should('have.value', ccAddress) - cy.contains('label', /^BCC$/).should('not.exist') + cy.contains('label', /^CC$/).should('not.exist') + recipientRow(/^BCC$/).find('input').should('have.value', bccAddress) sendReply('Reply after switching back') - expectSentRecipients(ccConversationUuid, { cc: [ccAddress], bcc: [] }) + expectSentRecipients(ccConversationUuid, { cc: [], bcc: [bccAddress] }) }) it('sends a reply that appears in the thread', () => { diff --git a/frontend/cypress/e2e/ui/livechatFormBranches.cy.js b/frontend/cypress/e2e/ui/livechatFormBranches.cy.js index e3ec72ad..d3b22cf4 100644 --- a/frontend/cypress/e2e/ui/livechatFormBranches.cy.js +++ b/frontend/cypress/e2e/ui/livechatFormBranches.cy.js @@ -300,6 +300,30 @@ describe('Live chat inbox form: branches the other specs skip', () => { }) }) + for (const { name, path, message, skipBootCall } of [ + { name: 'settings', path: 'settings/general', message: 'Settings unavailable', skipBootCall: true }, + { name: 'business hours', path: 'business-hours', message: 'Business hours unavailable', skipBootCall: false } + ]) { + it(`loads proactive message stats when the ${name} request fails`, () => { + cy.intercept('GET', `**/api/v1/${path}`, { + statusCode: 500, + body: { status: 'error', message, error_type: 'GeneralException', data: null } + }) + // The app loads general settings once at boot, before the form mounts. Only later calls fail. + if (skipBootCall) { + cy.intercept({ method: 'GET', url: `**/api/v1/${path}`, times: 1 }, (req) => req.continue()) + } + cy.intercept('GET', `**/api/v1/inboxes/${inboxId}/campaign-stats*`).as('campaignStats') + + editInbox(inboxId) + cy.wait('@campaignStats').then(({ request, response }) => { + expect(response.statusCode).to.eq(200) + expect(new URL(request.url).searchParams.get('from')).to.match(/^\d{4}-\d{2}-\d{2}$/) + }) + cy.contains('[role="alert"]', message).should('exist') + }) + } + it('saves a proactive message condition and removes it again', () => { cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') diff --git a/frontend/cypress/e2e/ui/templateForm.cy.js b/frontend/cypress/e2e/ui/templateForm.cy.js index 74e17b99..b22cb330 100644 --- a/frontend/cypress/e2e/ui/templateForm.cy.js +++ b/frontend/cypress/e2e/ui/templateForm.cy.js @@ -3,8 +3,8 @@ const stamp = Date.now() const templateName = `Cypress Template ${stamp}` const renamedTemplate = `Cypress Template ${stamp} edited` -const templateBody = `Cypress template body ${stamp}` -const updatedBody = `Cypress template body ${stamp} edited` +const templateBody = `Cypress template body ${stamp} {{ template "content" . }}` +const updatedBody = `Cypress template body ${stamp} edited {{ template "content" . }}` const listPath = '/admin/templates' const filterList = (text) => cy.get('input[placeholder="Search"]').clear().type(text) From 4a3dd0eef3ef143430d1f5c8a39addae9bf9b617 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 20 Oct 2026 11:23:00 +0530 Subject: [PATCH 03/13] add regression coverage for release fixes --- frontend/cypress/e2e/adminFlows.cy.js | 27 +++++ .../e2e/integration/livechat/embed.cy.js | 108 ++++++++++++++++++ .../e2e/ui/conversationLifecycle.cy.js | 81 +++++++++++++ frontend/cypress/e2e/ui/emailInboxForm.cy.js | 24 ++++ .../cypress/e2e/ui/livechatFormBranches.cy.js | 15 +++ .../e2e/ui/notificationSettingsForm.cy.js | 25 ++++ 6 files changed, 280 insertions(+) diff --git a/frontend/cypress/e2e/adminFlows.cy.js b/frontend/cypress/e2e/adminFlows.cy.js index 252aea1a..05a4c98e 100644 --- a/frontend/cypress/e2e/adminFlows.cy.js +++ b/frontend/cypress/e2e/adminFlows.cy.js @@ -158,3 +158,30 @@ describe('Admin setup and outgoing conversation', () => { } }) }) + +describe('Recovering a stale route after deployment', () => { + it('reloads a missing route chunk once and opens the requested page', () => { + let loads = 0 + cy.login() + cy.viewport(1280, 800) + cy.on('window:before:load', () => { loads++ }) + cy.on('uncaught:exception', (error) => { + if (error.message.includes('Failed to fetch dynamically imported module:') && + error.message.includes('/assets/WebhookList-')) return false + }) + cy.intercept({ method: 'GET', url: '**/assets/WebhookList-*.js', times: 1 }, { + statusCode: 404, body: '', headers: { 'content-type': 'text/javascript' } + }).as('missingRouteChunk') + cy.visit('/admin/conversations/tags') + cy.contains('button', /^Integrations$/).click() + cy.get('a[href="/admin/webhooks"]').first().scrollIntoView() + cy.get('a[href="/admin/webhooks"]').first().click() + cy.wait('@missingRouteChunk').its('response.statusCode').should('eq', 404) + cy.location('pathname').should('eq', '/admin/webhooks') + cy.contains('button', 'New webhook').should('be.visible') + cy.window().should((win) => { + expect(loads).to.eq(2) + expect(win.sessionStorage.getItem('libredesk:preload-reload')).to.eq(null) + }) + }) +}) diff --git a/frontend/cypress/e2e/integration/livechat/embed.cy.js b/frontend/cypress/e2e/integration/livechat/embed.cy.js index 267c2b28..94665e6f 100644 --- a/frontend/cypress/e2e/integration/livechat/embed.cy.js +++ b/frontend/cypress/e2e/integration/livechat/embed.cy.js @@ -19,6 +19,114 @@ describe('Live chat widget embedded on a host page', () => { }) }) + it('keeps the launcher usable by keyboard under host button styles', () => { + cy.createLivechatInbox(embedConfig()).then((inbox) => { + cy.visitWidgetHost(inbox.uuid) + cy.document().then((doc) => { + const style = doc.createElement('style') + style.textContent = 'button { margin: 30px; padding: 40px; min-width: 280px; background-image: linear-gradient(black, white); border: 10px solid black; }' + doc.head.appendChild(style) + }) + cy.widgetLauncher().should('be.visible').and('have.prop', 'tagName', 'BUTTON') + .and('have.attr', 'type', 'button') + .and('have.attr', 'aria-expanded', 'false') + .and('have.attr', 'aria-label', 'Open chat') + cy.widgetLauncher().then(($button) => { + const button = $button[0] + const style = button.ownerDocument.defaultView.getComputedStyle(button) + expect(button.getBoundingClientRect().width).to.eq(60) + expect(button.getBoundingClientRect().height).to.eq(60) + expect(style.margin).to.eq('0px') + expect(style.backgroundImage).to.eq('none') + }) + cy.viewport(390, 844) + cy.widgetLauncher().focus() + cy.widgetLauncher().type('{enter}') + cy.widgetLauncher().should('have.attr', 'aria-expanded', 'true') + .and('have.attr', 'aria-label', 'Close chat') + cy.widgetBody().find('button[aria-label="Close chat"]').focus() + cy.widgetBody().find('button[aria-label="Close chat"]').click() + cy.widgetLauncher().should('be.focused').and('have.attr', 'aria-expanded', 'false') + cy.viewport(1000, 660) + cy.widgetLauncher().type(' ') + cy.get('iframe[src*="/widget?inbox_id="]').should('be.visible') + cy.widgetLauncher().focus() + cy.widgetLauncher().type(' ') + cy.get('iframe[src*="/widget?inbox_id="]').should('not.be.visible') + }) + }) + + it('keeps the original image visible after a missing thumbnail and reconnect', () => { + cy.createLivechatInbox(embedConfig({ features: { file_upload: true } })).then((inbox) => { + cy.intercept('GET', '**/uploads/thumb_*', { statusCode: 404, body: '' }).as('missingThumbnail') + cy.intercept('GET', '**/api/v1/widget/chat/conversations/*').as('openedConversation') + cy.visitWidgetHost(inbox.uuid) + cy.widgetLauncher().click() + cy.widgetBody().contains(startButtonText).click() + cy.widgetSend('Image fallback conversation') + cy.wait('@openedConversation') + cy.widgetBody().find('input[type="file"]').selectFile('../static/public/launcher-logo.png', { force: true }) + cy.wait('@missingThumbnail') + const image = () => cy.widgetBody().find('[data-message-uuid] img[alt="launcher-logo.png"]') + image().should(($image) => { + expect($image[0].naturalWidth).to.be.greaterThan(0) + expect($image.attr('src')).not.to.include('/thumb_') + }) + let originalImage + image().then(($image) => { originalImage = $image[0] }) + cy.intercept('GET', '**/api/v1/widget/chat/conversations/*', (req) => { + req.continue((res) => { + for (const message of res.body.data.messages) { + for (const attachment of message.attachments || []) { + if (attachment.name !== 'launcher-logo.png') continue + const thumbnail = new URL(attachment.thumbnail_url) + thumbnail.searchParams.set('refresh', '1') + attachment.thumbnail_url = thumbnail.href + } + } + }) + }).as('recoveredConversation') + cy.window().then((win) => { + const frame = win.Libredesk.iframe.contentWindow + frame.dispatchEvent(new frame.Event('online')) + }) + cy.wait('@recoveredConversation').its('response.statusCode').should('eq', 200) + image().should(($image) => { + expect($image[0]).to.eq(originalImage) + expect($image[0].naturalWidth).to.be.greaterThan(0) + expect($image.attr('src')).not.to.include('/thumb_') + }) + }) + }) + + for (const rating of [0, 5]) { + it(`shows submitted CSAT feedback live in the agent view with rating ${rating}`, () => { + const feedback = `Live customer feedback ${rating} ${Date.now()}` + let inbox + let session + let originalDocument + cy.createLivechatInbox({}, { csat_enabled: true }).then((created) => { inbox = created }) + cy.then(() => cy.widgetInit(inbox.uuid)).then((created) => { session = created }) + cy.then(() => cy.visit(`/inboxes/all/conversation/${session.conversationUuid}`)) + cy.document().then((doc) => { originalDocument = doc }) + cy.then(() => cy.api('PUT', `/api/v1/conversations/${session.conversationUuid}/status`, { + status: 'Resolved' + })).its('status').should('eq', 200) + cy.contains('[data-message-uuid]', 'Awaiting Response', { timeout: 20000 }).should('be.visible') + cy.then(() => cy.widgetApi('GET', `/api/v1/widget/chat/conversations/${session.conversationUuid}`, + session.sessionToken, inbox.uuid)).then(({ body }) => { + const survey = body.data.messages.find((message) => message.meta?.is_csat) + expect(survey?.meta.csat_uuid).to.be.a('string') + cy.request('POST', `/api/v1/csat/${survey.meta.csat_uuid}/response`, { rating, feedback }) + .its('status').should('eq', 200) + cy.get(`[data-message-uuid="${survey.uuid}"]`).should('contain.text', feedback) + .and('not.contain.text', 'Awaiting Response') + if (rating) cy.get(`[data-message-uuid="${survey.uuid}"]`).should('contain.text', '5/5') + }) + cy.document().should((doc) => { expect(doc).to.eq(originalDocument) }) + }) + } + it('moves the launcher when the inbox launcher position changes', () => { cy.createLivechatInbox( embedConfig({ launcher: { position: 'right', spacing: { side: 20, bottom: 20 } } }) diff --git a/frontend/cypress/e2e/ui/conversationLifecycle.cy.js b/frontend/cypress/e2e/ui/conversationLifecycle.cy.js index 3139a82b..989d429f 100644 --- a/frontend/cypress/e2e/ui/conversationLifecycle.cy.js +++ b/frontend/cypress/e2e/ui/conversationLifecycle.cy.js @@ -349,6 +349,87 @@ describe('Conversation lifecycle', () => { expectSentRecipients(ccConversationUuid, { cc: [], bcc: [] }) }) + for (const field of ['CC', 'BCC']) { + it(`keeps ${field} open and focused while replacing an address`, () => { + openConversation(ccConversationUuid) + if (field === 'BCC') { + cy.contains('button', /^BCC$/).click() + recipientRow(/^BCC$/).find('input').type(bccAddress) + } + recipientRow(new RegExp(`^${field}$`)).find('input').clear() + recipientRow(new RegExp(`^${field}$`)).find('input') + .should('be.focused').and('have.value', '') + .type('replacement@example.com') + recipientRow(new RegExp(`^${field}$`)).find('input') + .should('have.value', 'replacement@example.com') + }) + } + + it('restores edited reply recipients and sender after reload and a failed send', () => { + const sender = 'billing@example.com' + const extraRecipient = 'draft.recipient@example.com' + const copy = 'draft.copy@example.com' + const content = `Reply draft ${stamp}` + const update = `Another agent replied ${stamp}` + cy.intercept('GET', `**/api/v1/conversations/${ccConversationUuid}`, (req) => { + req.continue((res) => { + res.body.data.inbox_aliases = [{ email: sender, verification_status: 'verified' }] + }) + }) + cy.intercept('POST', `**/conversations/${ccConversationUuid}/draft`, (req) => { + if (req.body.content?.includes(content)) req.alias = 'savedReplyDraft' + }) + openConversation(ccConversationUuid) + recipientRow(/^From$/).find('[role="combobox"]').click() + cy.contains('[role="option"]', sender).trigger('keydown', { key: 'Enter' }) + recipientRow(/^To$/).find('input').clear() + recipientRow(/^To$/).find('input').type(`${ccCustomerEmail}, ${extraRecipient}`) + recipientRow(/^CC$/).find('input').clear() + recipientRow(/^CC$/).find('input').type(copy) + cy.contains('button', /^BCC$/).click() + recipientRow(/^BCC$/).find('input').type(bccAddress) + cy.get('.tiptap.ProseMirror').first().type(content) + cy.wait('@savedReplyDraft').its('response.statusCode').should('eq', 200) + + const checkDraft = () => { + recipientRow(/^From$/).find('[role="combobox"]').should('contain.text', sender) + recipientRow(/^To$/).find('input').should('have.value', `${ccCustomerEmail}, ${extraRecipient}`) + recipientRow(/^CC$/).find('input').should('have.value', copy) + recipientRow(/^BCC$/).find('input').should('have.value', bccAddress) + cy.get('.tiptap.ProseMirror').first().should('contain.text', content) + } + cy.reload() + cy.wait('@loadMessages') + checkDraft() + cy.api('POST', `/api/v1/conversations/${ccConversationUuid}/messages`, { + sender_type: 'agent', private: false, message: `

${update}

`, + to: [ccCustomerEmail], cc: [ccAddress], bcc: [] + }).its('status').should('eq', 200) + cy.contains('[data-message-uuid]', update).should('be.visible') + checkDraft() + + cy.intercept('POST', `**/conversations/${ccConversationUuid}/messages`, { + statusCode: 500, body: { status: 'error', message: 'Reply failed', error_type: 'GeneralException' } + }).as('failedReply') + cy.contains('button', /^Send$/).click() + cy.wait('@failedReply').its('response.statusCode').should('eq', 500) + cy.reload() + cy.wait('@loadMessages') + checkDraft() + + captureSend() + cy.intercept('DELETE', `**/conversations/${ccConversationUuid}/draft*`).as('clearedReplyDraft') + cy.contains('button', /^Send$/).click() + cy.wait('@captureSend').then(({ request }) => { + expect(request.body.send_from).to.eq(sender) + expect(request.body.to).to.deep.eq([ccCustomerEmail, extraRecipient]) + expect(request.body.cc).to.deep.eq([copy]) + expect(request.body.bcc).to.deep.eq([bccAddress]) + }) + cy.wait('@clearedReplyDraft').its('response.statusCode').should('eq', 200) + cy.get('.tiptap.ProseMirror').first().should('have.text', '') + }) + it('preserves edited recipients when switching between conversations', () => { captureSend() openConversation(ccConversationUuid) diff --git a/frontend/cypress/e2e/ui/emailInboxForm.cy.js b/frontend/cypress/e2e/ui/emailInboxForm.cy.js index 943680ef..989afe8c 100644 --- a/frontend/cypress/e2e/ui/emailInboxForm.cy.js +++ b/frontend/cypress/e2e/ui/emailInboxForm.cy.js @@ -140,6 +140,30 @@ describe('Email inbox form', () => { cy.contains('New support inbox').should('be.visible') }) + for (const protocol of ['None', 'Login']) { + it(`validates empty SMTP credentials with ${protocol} authentication`, () => { + openManualForm() + fillRequiredFields() + field('smtp.username').clear() + field('smtp.password').clear() + cy.toggleEmailInboxAdvanced('smtp') + cy.contains('label', 'Authentication Protocol').parent().find('[role="combobox"]').click() + cy.contains('[role="option"]', new RegExp(`^${protocol}$`)).click() + submit() + if (protocol === 'None') { + cy.wait('@createInbox').then(({ request }) => { + expect(request.body.config.smtp[0].auth_protocol).to.eq('none') + expect(request.body.config.smtp[0].username).to.eq('') + expect(request.body.config.smtp[0].password).to.eq('') + }) + } else { + cy.get('@createInbox.all').should('have.length', 0) + field('smtp.username').parent().parent().should('contain.text', 'Required') + field('smtp.password').parent().parent().should('contain.text', 'Required') + } + }) + } + it('loads saved fields once and saves values from closed sections', () => { cy.visit(editPath) field('name').should('have.value', 'Support') diff --git a/frontend/cypress/e2e/ui/livechatFormBranches.cy.js b/frontend/cypress/e2e/ui/livechatFormBranches.cy.js index d3b22cf4..67518e97 100644 --- a/frontend/cypress/e2e/ui/livechatFormBranches.cy.js +++ b/frontend/cypress/e2e/ui/livechatFormBranches.cy.js @@ -324,6 +324,21 @@ describe('Live chat inbox form: branches the other specs skip', () => { }) } + it('uses workspace dates for campaign statistics across a UTC day boundary', () => { + cy.clock(Date.UTC(2026, 0, 1, 23, 30), ['Date']) + cy.intercept('GET', '**/api/v1/settings/general', (req) => { + req.continue((res) => { res.body.data['app.timezone'] = 'Pacific/Kiritimati' }) + }) + cy.intercept('GET', `**/api/v1/inboxes/${inboxId}/campaign-stats*`).as('workspaceStats') + editInbox(inboxId) + cy.wait('@workspaceStats').then(({ request, response }) => { + expect(response.statusCode).to.eq(200) + const params = new URL(request.url).searchParams + expect(params.get('from')).to.eq('2025-12-03') + expect(params.get('to')).to.eq('2026-01-02') + }) + }) + it('saves a proactive message condition and removes it again', () => { cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') diff --git a/frontend/cypress/e2e/ui/notificationSettingsForm.cy.js b/frontend/cypress/e2e/ui/notificationSettingsForm.cy.js index 3c455330..d6948de2 100644 --- a/frontend/cypress/e2e/ui/notificationSettingsForm.cy.js +++ b/frontend/cypress/e2e/ui/notificationSettingsForm.cy.js @@ -147,4 +147,29 @@ describe('Notification settings form', () => { cy.contains('p.text-destructive', 'Invalid duration format').should('exist') cy.get('@saveNotification.all').should('have.length', 0) }) + + for (const protocol of ['None', 'Login']) { + it(`validates empty notification SMTP credentials with ${protocol} authentication`, () => { + cy.intercept('PUT', '**/api/v1/settings/notifications/email').as('saveNotification') + cy.visit(path) + cy.get('input[name="host"]').should('not.have.value', '') + cy.get('input[name="username"]').clear() + cy.get('input[name="password"]').clear() + cy.contains('label', 'Authentication Protocol').parent().find('[role="combobox"]').click() + cy.contains('[role="option"]', new RegExp(`^${protocol}$`)).click() + cy.get('button[type="submit"]').click() + if (protocol === 'None') { + cy.wait('@saveNotification').then(({ request, response }) => { + expect(response.statusCode).to.eq(200) + expect(request.body[`${prefix}auth_protocol`]).to.eq('none') + expect(request.body[`${prefix}username`]).to.eq('') + expect(request.body[`${prefix}password`]).to.eq('') + }) + } else { + cy.get('@saveNotification.all').should('have.length', 0) + cy.get('input[name="username"]').parent().parent().should('contain.text', 'Required') + cy.get('input[name="password"]').parent().parent().should('contain.text', 'Required') + } + }) + } }) From 10ee92b0f341fb5fa5c166839acfb4c96c194728 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 20 Oct 2026 12:37:36 +0530 Subject: [PATCH 04/13] remember the minimized reply box state --- .../src/features/conversation/ReplyBox.vue | 3 +- .../e2e/ui/conversationLifecycle.cy.js | 95 +++++++++++++++++++ frontend/cypress/support/commands.js | 1 + 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue index 53fe0269..06157271 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBox.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue @@ -184,6 +184,7 @@ diff --git a/frontend/cypress/e2e/ui/emailInboxForm.cy.js b/frontend/cypress/e2e/ui/emailInboxForm.cy.js index 989afe8c..323070af 100644 --- a/frontend/cypress/e2e/ui/emailInboxForm.cy.js +++ b/frontend/cypress/e2e/ui/emailInboxForm.cy.js @@ -140,8 +140,13 @@ describe('Email inbox form', () => { cy.contains('New support inbox').should('be.visible') }) - for (const protocol of ['None', 'Login']) { - it(`validates empty SMTP credentials with ${protocol} authentication`, () => { + for (const [protocol, value] of [ + ['None', 'none'], + ['Login', 'login'], + ['CRAM', 'cram'], + ['Plain', 'plain'] + ]) { + it(`validates and saves SMTP credentials with ${protocol} authentication`, () => { openManualForm() fillRequiredFields() field('smtp.username').clear() @@ -160,6 +165,22 @@ describe('Email inbox form', () => { cy.get('@createInbox.all').should('have.length', 0) field('smtp.username').parent().parent().should('contain.text', 'Required') field('smtp.password').parent().parent().should('contain.text', 'Required') + field('smtp.username').type('support@example.com') + submit() + field('smtp.password').should('have.attr', 'aria-invalid', 'true') + cy.get('@createInbox.all').should('have.length', 0) + field('smtp.username').clear() + field('smtp.password').type('test-password') + submit() + field('smtp.username').should('have.attr', 'aria-invalid', 'true') + cy.get('@createInbox.all').should('have.length', 0) + field('smtp.username').type('support@example.com') + submit() + cy.wait('@createInbox').then(({ request }) => { + expect(request.body.config.smtp[0].auth_protocol).to.eq(value) + expect(request.body.config.smtp[0].username).to.eq('support@example.com') + expect(request.body.config.smtp[0].password).to.eq('test-password') + }) } }) } diff --git a/frontend/cypress/e2e/ui/livechatFormBranches.cy.js b/frontend/cypress/e2e/ui/livechatFormBranches.cy.js index 67518e97..e3ec72ad 100644 --- a/frontend/cypress/e2e/ui/livechatFormBranches.cy.js +++ b/frontend/cypress/e2e/ui/livechatFormBranches.cy.js @@ -300,45 +300,6 @@ describe('Live chat inbox form: branches the other specs skip', () => { }) }) - for (const { name, path, message, skipBootCall } of [ - { name: 'settings', path: 'settings/general', message: 'Settings unavailable', skipBootCall: true }, - { name: 'business hours', path: 'business-hours', message: 'Business hours unavailable', skipBootCall: false } - ]) { - it(`loads proactive message stats when the ${name} request fails`, () => { - cy.intercept('GET', `**/api/v1/${path}`, { - statusCode: 500, - body: { status: 'error', message, error_type: 'GeneralException', data: null } - }) - // The app loads general settings once at boot, before the form mounts. Only later calls fail. - if (skipBootCall) { - cy.intercept({ method: 'GET', url: `**/api/v1/${path}`, times: 1 }, (req) => req.continue()) - } - cy.intercept('GET', `**/api/v1/inboxes/${inboxId}/campaign-stats*`).as('campaignStats') - - editInbox(inboxId) - cy.wait('@campaignStats').then(({ request, response }) => { - expect(response.statusCode).to.eq(200) - expect(new URL(request.url).searchParams.get('from')).to.match(/^\d{4}-\d{2}-\d{2}$/) - }) - cy.contains('[role="alert"]', message).should('exist') - }) - } - - it('uses workspace dates for campaign statistics across a UTC day boundary', () => { - cy.clock(Date.UTC(2026, 0, 1, 23, 30), ['Date']) - cy.intercept('GET', '**/api/v1/settings/general', (req) => { - req.continue((res) => { res.body.data['app.timezone'] = 'Pacific/Kiritimati' }) - }) - cy.intercept('GET', `**/api/v1/inboxes/${inboxId}/campaign-stats*`).as('workspaceStats') - editInbox(inboxId) - cy.wait('@workspaceStats').then(({ request, response }) => { - expect(response.statusCode).to.eq(200) - const params = new URL(request.url).searchParams - expect(params.get('from')).to.eq('2025-12-03') - expect(params.get('to')).to.eq('2026-01-02') - }) - }) - it('saves a proactive message condition and removes it again', () => { cy.intercept('PUT', `**/api/v1/inboxes/${inboxId}`).as('updateInbox') diff --git a/frontend/cypress/e2e/ui/notificationSettingsForm.cy.js b/frontend/cypress/e2e/ui/notificationSettingsForm.cy.js index d6948de2..8403f061 100644 --- a/frontend/cypress/e2e/ui/notificationSettingsForm.cy.js +++ b/frontend/cypress/e2e/ui/notificationSettingsForm.cy.js @@ -148,8 +148,13 @@ describe('Notification settings form', () => { cy.get('@saveNotification.all').should('have.length', 0) }) - for (const protocol of ['None', 'Login']) { - it(`validates empty notification SMTP credentials with ${protocol} authentication`, () => { + for (const [protocol, value] of [ + ['None', 'none'], + ['Login', 'login'], + ['CRAM-MD5', 'cram'], + ['Plain', 'plain'] + ]) { + it(`validates and saves notification SMTP credentials with ${protocol} authentication`, () => { cy.intercept('PUT', '**/api/v1/settings/notifications/email').as('saveNotification') cy.visit(path) cy.get('input[name="host"]').should('not.have.value', '') @@ -169,6 +174,30 @@ describe('Notification settings form', () => { cy.get('@saveNotification.all').should('have.length', 0) cy.get('input[name="username"]').parent().parent().should('contain.text', 'Required') cy.get('input[name="password"]').parent().parent().should('contain.text', 'Required') + cy.get('input[name="username"]').type(seedUsername) + cy.get('button[type="submit"]').click() + cy.get('input[name="password"]').should('have.attr', 'aria-invalid', 'true') + cy.get('@saveNotification.all').should('have.length', 0) + cy.get('input[name="username"]').clear() + cy.get('input[name="password"]').type('test-password') + cy.get('button[type="submit"]').click() + cy.get('input[name="username"]').should('have.attr', 'aria-invalid', 'true') + cy.get('@saveNotification.all').should('have.length', 0) + cy.get('input[name="username"]').type(seedUsername) + cy.get('button[type="submit"]').click() + cy.wait('@saveNotification').then(({ request, response }) => { + expect(response.statusCode).to.eq(200) + expect(request.body[`${prefix}auth_protocol`]).to.eq(value) + expect(request.body[`${prefix}username`]).to.eq(seedUsername) + expect(request.body[`${prefix}password`]).to.eq('test-password') + }) + cy.reload() + cy.contains('label', 'Authentication Protocol') + .parent() + .find('[role="combobox"]') + .should('contain.text', protocol) + cy.get('input[name="username"]').should('have.value', seedUsername) + cy.get('input[name="password"]').should('have.value', maskedPassword) } }) } From 61ff12b32121a3f4d4a790eedc4ece935b6d225e Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 21 Oct 2026 02:33:30 +0530 Subject: [PATCH 10/13] explain outgoing template validation --- internal/template/render.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/template/render.go b/internal/template/render.go index e9a2d071..6e9c445e 100644 --- a/internal/template/render.go +++ b/internal/template/render.go @@ -228,6 +228,7 @@ func personData(person umodels.User) map[string]any { } } +// parseOutgoingTemplate rejects invalid syntax, missing reply calls, and unconditional duplicates. Conditional reply counts are checked during rendering. func parseOutgoingTemplate(body string, funcs template.FuncMap) (*template.Template, error) { tmpl, err := template.New(TmplBase).Funcs(funcs).Parse(body) if err != nil { From 62fe692c53db0c21aeccf1a49e7459e804e09af7 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 21 Oct 2026 02:40:40 +0530 Subject: [PATCH 11/13] allow repeated replies in outgoing email templates --- i18n/en-US.json | 2 +- internal/template/render.go | 41 ++--------------- internal/template/template_test.go | 74 +++++++++++++++++------------- 3 files changed, 46 insertions(+), 71 deletions(-) diff --git a/i18n/en-US.json b/i18n/en-US.json index 1036e9f8..bc272159 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -1714,7 +1714,7 @@ "template.defaultTemplateAlreadyExists": "Another outgoing email template is already the default. Clear its default setting before choosing this template.", "template.deletionConfirmation": "This action cannot be undone. This will permanently delete this template.", "template.edit": "Edit template", - "template.invalidOutgoingTemplate": "This email template cannot deliver replies. Check the syntax and include {content} exactly once.", + "template.invalidOutgoingTemplate": "This email template cannot deliver replies. Check the syntax and include {content}.", "toast.apiKeyGenerated": "API key generated", "toast.authorizationDenied": "Authorization denied", "toast.avatarUpdated": "Avatar updated successfully", diff --git a/internal/template/render.go b/internal/template/render.go index 6e9c445e..679f46c6 100644 --- a/internal/template/render.go +++ b/internal/template/render.go @@ -98,8 +98,8 @@ func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, err return "", fmt.Errorf("executing base template: %w", err) } - if contentCount != 1 { - return "", fmt.Errorf("outgoing template rendered the reply %d times, expected once", contentCount) + if contentCount == 0 { + return "", fmt.Errorf("outgoing template did not render the reply") } return rendered.String(), nil } @@ -228,7 +228,7 @@ func personData(person umodels.User) map[string]any { } } -// parseOutgoingTemplate rejects invalid syntax, missing reply calls, and unconditional duplicates. Conditional reply counts are checked during rendering. +// parseOutgoingTemplate checks syntax and requires a reply placeholder. Rendering checks that the reply actually appears. func parseOutgoingTemplate(body string, funcs template.FuncMap) (*template.Template, error) { tmpl, err := template.New(TmplBase).Funcs(funcs).Parse(body) if err != nil { @@ -241,44 +241,9 @@ func parseOutgoingTemplate(body string, funcs template.FuncMap) (*template.Templ if count == 0 { return nil, fmt.Errorf("outgoing template contains no reply template") } - if countRequiredContentTemplates(tmpl.Tree.Root, tmpl, make(map[string]bool)) > 1 { - return nil, fmt.Errorf("outgoing template repeats the reply template") - } return tmpl, nil } -func countRequiredContentTemplates(node parse.Node, tmpl *template.Template, visiting map[string]bool) int { - switch node := node.(type) { - case *parse.TemplateNode: - if node.Name == TmplContent { - return 1 - } - if visiting[node.Name] { - return 0 - } - if part := tmpl.Lookup(node.Name); part != nil { - visiting[node.Name] = true - count := countRequiredContentTemplates(part.Tree.Root, tmpl, visiting) - delete(visiting, node.Name) - return count - } - case *parse.ListNode: - if node == nil { - return 0 - } - count := 0 - for _, child := range node.Nodes { - count += countRequiredContentTemplates(child, tmpl, visiting) - } - return count - case *parse.IfNode: - return min(countRequiredContentTemplates(node.List, tmpl, visiting), countRequiredContentTemplates(node.ElseList, tmpl, visiting)) - case *parse.WithNode: - return min(countRequiredContentTemplates(node.List, tmpl, visiting), countRequiredContentTemplates(node.ElseList, tmpl, visiting)) - } - return 0 -} - func countContentTemplates(node parse.Node) int { switch node := node.(type) { case *parse.TemplateNode: diff --git a/internal/template/template_test.go b/internal/template/template_test.go index d988ea39..90a0dc47 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -73,26 +73,26 @@ func TestValidateOutgoingTemplate(t *testing.T) { {"empty", "", false}, {"missing", "

Header

", false}, {"single", `{{ template "content" . }}`, true}, - {"duplicate", `{{ template "content" . }}{{ template "content" . }}`, false}, - {"triple", `{{ template "content" . }}{{ template "content" . }}{{ template "content" . }}`, false}, - {"trimmed duplicate", `{{- template "content" . -}}

Footer

{{- template "content" . -}}`, false}, + {"duplicate", `{{ template "content" . }}{{ template "content" . }}`, true}, + {"triple", `{{ template "content" . }}{{ template "content" . }}{{ template "content" . }}`, true}, + {"trimmed duplicate", `{{- template "content" . -}}

Footer

{{- template "content" . -}}`, true}, {"comment only", `{{/* template "content" . */}}`, false}, {"comment and single", `{{/* template "content" . */}}{{ template "content" . }}`, true}, {"malformed", `{{ template "content" .`, false}, {"wrong name", `{{ template "contents" . }}`, false}, {"literal template text", `{{ printf "%s" "{{ template \"content\" . }}" }}`, false}, {"nested wrappers", `{{ define "inner" }}{{ template "content" . }}{{ end }}{{ define "outer" }}{{ template "inner" . }}{{ end }}{{ template "outer" . }}`, true}, - {"nested duplicate", `{{ define "inner" }}{{ template "content" . }}{{ end }}{{ define "outer" }}{{ template "inner" . }}{{ template "inner" . }}{{ end }}{{ template "outer" . }}`, false}, + {"nested duplicate", `{{ define "inner" }}{{ template "content" . }}{{ end }}{{ define "outer" }}{{ template "inner" . }}{{ template "inner" . }}{{ end }}{{ template "outer" . }}`, true}, {"unused duplicate", `{{ define "unused" }}{{ template "content" . }}{{ template "content" . }}{{ end }}{{ template "content" . }}`, true}, - {"duplicate alternatives", `{{ if .Show }}{{ template "content" . }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ template "content" . }}{{ end }}`, false}, + {"duplicate alternatives", `{{ if .Show }}{{ template "content" . }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ template "content" . }}{{ end }}`, true}, {"nested alternatives", `{{ if .Show }}{{ with .Author }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}{{ else }}{{ template "content" . }}{{ end }}`, true}, {"dynamic conditional", `{{ template "content" . }}{{ if .Show }}{{ template "content" . }}{{ end }}`, true}, {"alternatives", `{{ if .Show }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}`, true}, {"with alternatives", `{{ with .Author }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}`, true}, - {"duplicate with alternatives", `{{ template "content" . }}{{ with .Author }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}`, false}, + {"duplicate with alternatives", `{{ template "content" . }}{{ with .Author }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}`, true}, {"unused definition", `{{ define "unused" }}{{ template "content" . }}{{ end }}{{ template "content" . }}`, true}, {"single wrapper", `{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}`, true}, - {"repeated wrapper", `{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}{{ template "wrapper" . }}`, false}, + {"repeated wrapper", `{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}{{ template "wrapper" . }}`, true}, {"previous messages", `{{ template "content" . }}{{ range .PreviousMessages }}{{ .Content }}{{ end }}`, true}, {"dynamic loop", `{{ range . }}{{ template "content" . }}{{ end }}`, true}, {"recursive definition", `{{ define "wrapper" }}{{ template "wrapper" . }}{{ end }}{{ template "wrapper" . }}{{ template "content" . }}`, true}, @@ -123,12 +123,6 @@ func TestOutgoingTemplatePreservesReply(t *testing.T) { "

Support header

", `{{/* template "content" . */}}`, `{{ template "content" .`, - `{{ template "content" . }}{{ template "content" . }}`, - `{{- template "content" . -}}

Footer

{{- template "content" . -}}`, - `{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}{{ template "wrapper" . }}`, - `{{ template "content" . }}{{ if .Show }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}`, - `{{ define "inner" }}{{ template "content" . }}{{ end }}{{ define "outer" }}{{ template "inner" . }}{{ template "inner" . }}{{ end }}{{ template "outer" . }}`, - `{{ if .Show }}{{ template "content" . }}{{ template "content" . }}{{ else }}{{ template "content" . }}{{ template "content" . }}{{ end }}`, } { t.Run("reject "+body, func(t *testing.T) { candidate := valid @@ -154,28 +148,44 @@ func TestOutgoingTemplatePreservesReply(t *testing.T) { if err != nil || stored.Body != valid.Body { t.Fatalf("rejected edit changed template: %+v, %v", stored, err) } + for _, body := range []string{ + `{{ template "content" . }}{{ template "content" . }}`, + `{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}{{ template "wrapper" . }}`, + `{{ range . }}{{ template "content" . }}{{ end }}`, + } { + candidate := valid + candidate.Body = body + candidate.IsDefault = false + if _, err := m.Create(candidate); err != nil { + t.Fatalf("could not create repeated reply template: %v", err) + } + candidate.IsDefault = true + if _, err := m.Update(saved.ID, candidate); err != nil { + t.Fatalf("could not update repeated reply template: %v", err) + } + } const conditional = `{{ if .Show }}

Hello

{{ template "content" . }}{{ else }}{{ template "content" . }}{{ end }}` if _, err := m.Update(saved.ID, models.Template{Name: valid.Name, Type: valid.Type, IsDefault: true, Body: conditional}); err != nil { t.Fatalf("rejected a conditional layout that renders the reply once: %v", err) } for _, tc := range []struct { - body string - data any - ok bool + body string + data any + count int }{ - {valid.Body, nil, true}, - {`{{template "content" .}}`, nil, true}, - {`{{- template "content" . -}}`, nil, true}, - {`{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}`, nil, true}, - {`{{ if .Show }}{{ template "content" . }}{{ end }}`, map[string]any{"Show": true}, true}, - {`{{ if .Show }}{{ template "content" . }}{{ end }}`, map[string]any{"Show": false}, false}, - {conditional, map[string]any{"Show": true}, true}, - {conditional, map[string]any{"Show": false}, true}, - {`{{ template "content" . }}{{ template "content" . }}`, nil, false}, - {`{{ range . }}{{ template "content" . }}{{ end }}`, []string{}, false}, - {`{{ range . }}{{ template "content" . }}{{ end }}`, []string{"first", "second"}, false}, - {`{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}{{ template "wrapper" . }}`, nil, false}, - {`

Previously saved layout without a reply

`, nil, false}, + {valid.Body, nil, 1}, + {`{{template "content" .}}`, nil, 1}, + {`{{- template "content" . -}}`, nil, 1}, + {`{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}`, nil, 1}, + {`{{ if .Show }}{{ template "content" . }}{{ end }}`, map[string]any{"Show": true}, 1}, + {`{{ if .Show }}{{ template "content" . }}{{ end }}`, map[string]any{"Show": false}, 0}, + {conditional, map[string]any{"Show": true}, 1}, + {conditional, map[string]any{"Show": false}, 1}, + {`{{ template "content" . }}{{ template "content" . }}`, nil, 2}, + {`{{ range . }}{{ template "content" . }}{{ end }}`, []string{}, 0}, + {`{{ range . }}{{ template "content" . }}{{ end }}`, []string{"first", "second"}, 2}, + {`{{ define "wrapper" }}{{ template "content" . }}{{ end }}{{ template "wrapper" . }}{{ template "wrapper" . }}`, nil, 2}, + {`

Previously saved layout without a reply

`, nil, 0}, } { t.Run("render "+tc.body+fmt.Sprint(tc.data), func(t *testing.T) { if _, err := db.Exec(`UPDATE templates SET body = $1 WHERE id = $2`, tc.body, saved.ID); err != nil { @@ -183,9 +193,9 @@ func TestOutgoingTemplatePreservesReply(t *testing.T) { } const reply = "

Your receipt is ready. Café 日本語.

" got, err := m.RenderEmailWithTemplate(tc.data, reply) - if tc.ok { - if err != nil || strings.Count(got, reply) != 1 { - t.Fatalf("reply was lost or repeated: %q, %v", got, err) + if tc.count > 0 { + if err != nil || strings.Count(got, reply) != tc.count { + t.Fatalf("unexpected rendered reply: %q, %v", got, err) } } else if err == nil || got != "" { t.Fatalf("unsafe outgoing content accepted: %q, %v", got, err) From c9fb6c1ce1481c005d28726726b163aa550ecdf5 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 21 Oct 2026 02:46:14 +0530 Subject: [PATCH 12/13] normalize default template types on update --- internal/template/template.go | 3 +++ internal/template/template_test.go | 36 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/internal/template/template.go b/internal/template/template.go index d5877e15..495ef7ed 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -66,6 +66,9 @@ func New(lo *logf.Logger, db *sqlx.DB, webTpls *template.Template, tpls *templat // Update updates a new template with the given name, and body. func (m *Manager) Update(id int, t models.Template) (models.Template, error) { + if t.IsDefault { + t.Type = TypeEmailOutgoing + } if err := m.validateOutgoingTemplate(t); err != nil { return models.Template{}, err } diff --git a/internal/template/template_test.go b/internal/template/template_test.go index 90a0dc47..f49f4285 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -63,6 +63,42 @@ func TestDefaultTemplateConflict(t *testing.T) { } } +func TestUpdateDefaultTemplateType(t *testing.T) { + db := testutil.NewDB(t, "default_template_type") + lo := logf.New(logf.Opts{}) + m, err := New(&lo, db, nil /** webTpls **/, nil /** tpls **/, nil /** funcMap **/, testutil.NewI18n(t)) + if err != nil { + t.Fatal(err) + } + saved, err := m.Create(models.Template{Name: "Layout", Type: TypeEmailOutgoing, Body: `{{ template "content" . }}`}) + if err != nil { + t.Fatal(err) + } + for _, typ := range []string{TypeEmailNotification, "", "invalid", TypeEmailOutgoing} { + candidate := saved + candidate.Type = typ + candidate.IsDefault = true + updated, err := m.Update(saved.ID, candidate) + if err != nil || updated.Type != TypeEmailOutgoing || !updated.IsDefault { + t.Fatalf("default type %q was not normalized: %+v, %v", typ, updated, err) + } + stored, err := m.Get(saved.ID) + if err != nil || stored.Type != TypeEmailOutgoing || !stored.IsDefault { + t.Fatalf("incorrect persisted default: %+v, %v", stored, err) + } + candidate.Body = "

No reply placeholder

" + if _, err := m.Update(saved.ID, candidate); err == nil { + t.Fatalf("accepted default type %q without a reply", typ) + } + } + saved.Type = TypeEmailNotification + saved.Body = "

Notification

" + updated, err := m.Update(saved.ID, saved) + if err != nil || updated.Type != TypeEmailNotification || updated.IsDefault { + t.Fatalf("changed nondefault notification type: %+v, %v", updated, err) + } +} + func TestValidateOutgoingTemplate(t *testing.T) { m := &Manager{i18n: testutil.NewI18n(t)} for _, tc := range []struct { From 30de4ca40d1add6c99e169259dc97cbc1b7fe30d Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Wed, 21 Oct 2026 03:02:07 +0530 Subject: [PATCH 13/13] fix draft recipient test expectations --- .../e2e/ui/conversationLifecycle.cy.js | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/frontend/cypress/e2e/ui/conversationLifecycle.cy.js b/frontend/cypress/e2e/ui/conversationLifecycle.cy.js index 4f9cf2fd..4e0e39da 100644 --- a/frontend/cypress/e2e/ui/conversationLifecycle.cy.js +++ b/frontend/cypress/e2e/ui/conversationLifecycle.cy.js @@ -352,6 +352,7 @@ describe('Conversation lifecycle', () => { for (const field of ['CC', 'BCC']) { it(`keeps ${field} open and focused while replacing an address`, () => { openConversation(ccConversationUuid) + cy.get('.tiptap.ProseMirror[contenteditable="true"]').should('be.focused') if (field === 'BCC') { cy.contains('button', /^BCC$/).click() recipientRow(/^BCC$/).find('input').type(bccAddress) @@ -374,7 +375,7 @@ describe('Conversation lifecycle', () => { it(`preserves a minimized reply through reload and send ${copies.name}`, () => { const content = `Minimized reply ${copies.name} ${stamp}` const sender = `lifecycle+${stamp}@cypress.test` - const contactEmail = `minimized.${copies.name.replaceAll(' ', '.')}.${stamp}@example.com` + const contactEmail = `minimized.${copies.name.toLowerCase().replaceAll(' ', '.')}.${stamp}@example.com` const to = [contactEmail, 'second.recipient@example.com'] const uploads = [] let uuid @@ -383,9 +384,13 @@ describe('Conversation lifecycle', () => { content: '

Minimize test conversation.

', initiator: 'contact' }).then(({ body }) => { uuid = body.data.uuid + cy.api('POST', `/api/v1/conversations/${uuid}/messages`, { + sender_type: 'agent', private: false, message: '

Previous reply.

', + to, cc: copies.cc ? [copies.cc] : [], bcc: copies.bcc ? [copies.bcc] : [], + send_from: sender + }).its('status').should('eq', 200) cy.api('POST', `/api/v1/conversations/${uuid}/draft`, { - type: 'reply', content: `

${content}

`, - meta: { recipients: { to: to.join(', '), cc: copies.cc, bcc: copies.bcc, send_from: sender } } + type: 'reply', content: `

${content}

`, meta: {} }).its('status').should('eq', 200) openConversation(uuid) }) @@ -412,7 +417,7 @@ describe('Conversation lifecycle', () => { cy.get('.tiptap.ProseMirror').should('contain.text', content) recipientRow(/^To$/).find('input').should('have.value', to.join(', ')) for (const field of ['cc', 'bcc']) { - if (copies[field]) recipientRow(new RegExp(`^${field.toUpperCase()}$`)).find('input').should('have.value', copies[field]) + if (field === 'cc' && copies[field]) recipientRow(new RegExp(`^${field.toUpperCase()}$`)).find('input').should('have.value', copies[field]) else cy.contains('label', new RegExp(`^${field.toUpperCase()}$`)).should('not.exist') } cy.contains('reply-draft.txt').should('be.visible') @@ -436,6 +441,13 @@ describe('Conversation lifecycle', () => { cy.reload() cy.wait('@loadMessages') checkDraft() + if (copies.bcc) { + cy.contains('button', /^BCC$/).click() + recipientRow(/^BCC$/).find('input').type(copies.bcc) + cy.get('.box button[aria-label="Collapse"]').click() + cy.contains('button', content).click() + recipientRow(/^BCC$/).find('input').should('have.value', copies.bcc) + } cy.intercept('POST', '**/conversations/*/messages').as('sendMinimizedReply') cy.contains('button', /^Send$/).click() cy.wait('@sendMinimizedReply').then(({ request, response }) => { @@ -461,7 +473,7 @@ describe('Conversation lifecycle', () => { }) } - it('restores edited reply recipients and sender after reload and a failed send', () => { + it('restores reply content with conversation recipients after reload and a failed send', () => { const sender = 'billing@example.com' const extraRecipient = 'draft.recipient@example.com' const copy = 'draft.copy@example.com' @@ -485,13 +497,16 @@ describe('Conversation lifecycle', () => { cy.contains('button', /^BCC$/).click() recipientRow(/^BCC$/).find('input').type(bccAddress) cy.get('.tiptap.ProseMirror').first().type(content) - cy.wait('@savedReplyDraft').its('response.statusCode').should('eq', 200) + cy.wait('@savedReplyDraft').then(({ request, response }) => { + expect(response.statusCode).to.eq(200) + expect(request.body.meta).not.to.have.property('recipients') + }) const checkDraft = () => { - recipientRow(/^From$/).find('[role="combobox"]').should('contain.text', sender) - recipientRow(/^To$/).find('input').should('have.value', `${ccCustomerEmail}, ${extraRecipient}`) - recipientRow(/^CC$/).find('input').should('have.value', copy) - recipientRow(/^BCC$/).find('input').should('have.value', bccAddress) + recipientRow(/^From$/).find('[role="combobox"]').should('contain.text', `lifecycle+${stamp}@cypress.test`) + recipientRow(/^To$/).find('input').should('have.value', ccCustomerEmail) + recipientRow(/^CC$/).find('input').should('have.value', ccAddress) + cy.contains('label', /^BCC$/).should('not.exist') cy.get('.tiptap.ProseMirror').first().should('contain.text', content) } cy.reload() @@ -517,16 +532,16 @@ describe('Conversation lifecycle', () => { cy.intercept('DELETE', `**/conversations/${ccConversationUuid}/draft*`).as('clearedReplyDraft') cy.contains('button', /^Send$/).click() cy.wait('@captureSend').then(({ request }) => { - expect(request.body.send_from).to.eq(sender) - expect(request.body.to).to.deep.eq([ccCustomerEmail, extraRecipient]) - expect(request.body.cc).to.deep.eq([copy]) - expect(request.body.bcc).to.deep.eq([bccAddress]) + expect(request.body.send_from).to.eq(`lifecycle+${stamp}@cypress.test`) + expect(request.body.to).to.deep.eq([ccCustomerEmail]) + expect(request.body.cc).to.deep.eq([ccAddress]) + expect(request.body.bcc).to.deep.eq([]) }) cy.wait('@clearedReplyDraft').its('response.statusCode').should('eq', 200) cy.get('.tiptap.ProseMirror').first().should('have.text', '') }) - it('preserves edited recipients when switching between conversations', () => { + it('restores conversation recipients when switching between conversations', () => { captureSend() openConversation(ccConversationUuid) recipientRow(/^CC$/).find('button[aria-label="Remove CC"]').click() @@ -545,10 +560,10 @@ describe('Conversation lifecycle', () => { cy.contains(ccContactName).click() cy.location('pathname').should('include', ccConversationUuid) recipientRow(/^To$/).find('input').should('have.value', ccCustomerEmail) - cy.contains('label', /^CC$/).should('not.exist') - recipientRow(/^BCC$/).find('input').should('have.value', bccAddress) + recipientRow(/^CC$/).find('input').should('have.value', ccAddress) + cy.contains('label', /^BCC$/).should('not.exist') sendReply('Reply after switching back') - expectSentRecipients(ccConversationUuid, { cc: [], bcc: [bccAddress] }) + expectSentRecipients(ccConversationUuid, { cc: [ccAddress], bcc: [] }) }) it('sends a reply that appears in the thread', () => {