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" > -