Merge pull request #712 from abhinavxd/qa/2.9.0-release

fix issues found during release testing
This commit is contained in:
Abhinav Raut
2026-10-21 03:39:16 +05:30
committed by GitHub
33 changed files with 1224 additions and 63 deletions
+2 -3
View File
@@ -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)
}
+5 -5
View File
@@ -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)
}
@@ -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()
@@ -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()
})
@@ -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') })
}
}
});
@@ -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()
@@ -55,7 +55,7 @@
<div class="flex gap-2">
<Button type="submit" :isLoading="isLoading"> {{ submitLabel }} </Button>
<Button
v-if="initialValues?.is_builtin && defaultBody"
v-if="(isOutgoingTemplate || initialValues?.is_builtin) && defaultBody"
type="button"
variant="outline"
:disabled="isLoading"
@@ -88,7 +88,7 @@ import CodeEditor from '@main/components/editor/CodeEditor.vue'
import { Checkbox } from '@shared-ui/components/ui/checkbox/index.js'
import { Label } from '@shared-ui/components/ui/label/index.js'
import { useI18n } from 'vue-i18n'
import { BUILT_IN_TEMPLATE_BODIES } from './defaults.js'
import { BUILT_IN_TEMPLATE_BODIES, DEFAULT_OUTGOING_TEMPLATE_BODY } from './defaults.js'
const props = defineProps({
initialValues: {
@@ -133,6 +133,7 @@ const hideSubject = computed(() => {
})
const defaultBody = computed(() => {
if (isOutgoingTemplate.value) return DEFAULT_OUTGOING_TEMPLATE_BODY
return BUILT_IN_TEMPLATE_BODIES[props.initialValues?.name]
})
@@ -1,3 +1,45 @@
export const DEFAULT_OUTGOING_TEMPLATE_BODY = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
img { max-width: 100%; height: auto; }
a { color: #0055d4; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; background-color: #f4f4f4; padding: 12px; }
@media screen and (max-width: 600px) {
.email-container { padding: 16px 8px !important; }
.email-card { padding: 24px 20px !important; }
}
</style>
</head>
<body style="margin: 0; background-color: #f0f1f3; font-family: 'Helvetica Neue', 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 15px; line-height: 26px; color: #444;">
<div class="email-container" style="padding: 32px 16px;">
<div class="email-card" style="max-width: 525px; margin: 0 auto; padding: 32px; background-color: #fff; border-radius: 5px; overflow-wrap: anywhere;">
{{/* Optional logo. Replace the image URL and alt text, or remove this div. */}}
<div style="margin-bottom: 28px;">
<img src="https://libredesk.io/logo.png" alt="libredesk" width="150" style="display: block; width: 150px; max-width: 100%; height: auto; border: 0;">
</div>
{{/* Uses the recipient's first name when available, otherwise displays Hi. */}}
<p style="margin: 0 0 20px;">Hi{{ if .Recipient.FirstName }} {{ .Recipient.FirstName }}{{ end }},</p>
{{/* Required reply content. Keep the following template call exactly once. */}}
{{ template "content" . }}
{{/* Displays the signature when the author's first name is available. */}}
{{ if .Author.FirstName }}
<p style="margin: 24px 0 0;">
Best regards,<br>
{{ .Author.FirstName }}
</p>
{{ end }}
</div>
</div>
</body>
</html>`
export const BUILT_IN_TEMPLATE_BODIES = {
'Conversation assigned': `<p>Hi {{ .Recipient.FirstName }},</p>
@@ -184,6 +184,7 @@
<script setup>
import { ref, watch, computed, toRaw, nextTick, onMounted, onUnmounted } from 'vue'
import { useStorage } from '@vueuse/core'
import { handleHTTPError } from '@shared-ui/utils/http.js'
import { getTextFromHTML } from '@shared-ui/utils/string'
import { EMITTER_EVENTS } from '@main/constants/emitterEvents.js'
@@ -337,7 +338,7 @@ const {
// Rest of existing state
const isEditorFullscreen = ref(false)
const isMinimized = ref(false)
const isMinimized = useStorage('replyBoxMinimized', false)
const isSending = ref(false)
const isGenerating = ref(false)
const to = ref('')
+17
View File
@@ -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) {
@@ -5,18 +5,16 @@
:key="attachment.uuid"
class="flex items-center cursor-pointer"
>
<!-- Image preview -->
<div v-if="isImage(attachment)" class="relative">
<img
:src="getThumbnailUrl(attachment)"
:alt="attachment.name"
class="max-w-48 max-h-32 rounded-md object-cover"
@error="fallbackToOriginal($event, attachment.url)"
@error="failedThumbnails.add(attachment.uuid)"
@click="openImage(attachment.url)"
/>
</div>
<!-- File attachment -->
<div
v-else
class="flex items-center gap-2 p-2 bg-muted rounded-md border border-border hover:bg-muted/80 transition-colors"
@@ -35,6 +33,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { File } from 'lucide-vue-next';
import { formatBytes, getThumbFilepath } from '@shared-ui/utils/file';
defineProps({
@@ -44,21 +43,17 @@ defineProps({
}
})
const failedThumbnails = ref(new Set())
const isImage = (attachment) => {
return attachment.content_type && attachment.content_type.startsWith('image/')
}
const getThumbnailUrl = (attachment) => {
if (!isImage(attachment)) return attachment.url
if (!isImage(attachment) || failedThumbnails.value.has(attachment.uuid)) return attachment.url
return attachment.thumbnail_url || getThumbFilepath(attachment.url)
}
const fallbackToOriginal = (event, originalUrl) => {
if (event.target.dataset.originalFallback) return
event.target.dataset.originalFallback = 'true'
event.target.src = originalUrl
}
const openImage = (url) => {
window.open(url, '_blank', 'noopener,noreferrer')
}
@@ -56,6 +56,8 @@ export function useReplyPreviews() {
identity: String(user.userID || 'visitor'),
previews,
labels: {
openChat: t('globals.messages.openChat'),
closeChat: t('globals.messages.closeChat'),
dismiss: t('globals.terms.dismiss'),
dismissAll: t('widget.dismissPreviews'),
open: t('globals.messages.openConversation')
+27
View File
@@ -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)
})
})
})
+15 -4
View File
@@ -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: '<p>no reply</p>'
}, { 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: '<p>api body</p>',
body: `<p>api body</p>${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('<p>api body</p>')
expect(body.data.body).to.eq(`<p>api body</p>${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: '<p>updated</p>',
body: `<p>updated</p>${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('<p>updated</p>')
expect(body.data.body).to.eq(`<p>updated</p>${reply}`)
})
})
@@ -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 } } })
@@ -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,199 @@ describe('Conversation lifecycle', () => {
expectSentRecipients(ccConversationUuid, { cc: [], bcc: [] })
})
it('sends only what each conversation shows after switching between them', () => {
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)
}
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')
})
}
for (const copies of [
{ name: 'without copies', cc: '', bcc: '' },
{ name: 'with CC', cc: 'draft.copy@example.com', bcc: '' },
{ name: 'with BCC', cc: '', bcc: bccAddress },
{ name: 'with CC and BCC', cc: 'draft.copy@example.com', bcc: bccAddress }
]) {
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.toLowerCase().replaceAll(' ', '.')}.${stamp}@example.com`
const to = [contactEmail, 'second.recipient@example.com']
const uploads = []
let uuid
cy.api('POST', '/api/v1/conversations', {
inbox_id: inboxId, contact_email: contactEmail, first_name: 'Minimize', subject: content,
content: '<p>Minimize test conversation.</p>', initiator: 'contact'
}).then(({ body }) => {
uuid = body.data.uuid
cy.api('POST', `/api/v1/conversations/${uuid}/messages`, {
sender_type: 'agent', private: false, message: '<p>Previous reply.</p>',
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: `<p>${content}</p>`, meta: {}
}).its('status').should('eq', 200)
openConversation(uuid)
})
cy.get('.tiptap.ProseMirror').should('contain.text', content)
cy.intercept('POST', '**/api/v1/media').as('replyUpload')
cy.intercept('POST', '**/conversations/*/draft', (req) => {
if (req.body.meta?.attachments?.length === 2) req.alias = 'savedMinimizedDraft'
})
cy.get('input[type="file"]').first().selectFile([
{ contents: Cypress.Buffer.from('Reply attachment'), fileName: 'reply-draft.txt', mimeType: 'text/plain' },
{
contents: Cypress.Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jBz0AAAAASUVORK5CYII=', 'base64'),
fileName: 'reply-image.png', mimeType: 'image/png'
}
], { force: true })
for (let index = 0; index < 2; index++) {
cy.wait('@replyUpload').then(({ response }) => {
expect(response.statusCode).to.eq(200)
uploads.push(response.body.data)
})
}
cy.wait('@savedMinimizedDraft').its('response.statusCode').should('eq', 200)
const checkDraft = () => {
cy.get('.tiptap.ProseMirror').should('contain.text', content)
recipientRow(/^To$/).find('input').should('have.value', to.join(', '))
for (const field of ['cc', 'bcc']) {
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')
cy.contains('reply-image.png').should('be.visible')
}
checkDraft()
cy.get('.box button[aria-label="Collapse"]').click()
cy.window().its('localStorage').invoke('getItem', 'replyBoxMinimized').should('eq', 'true')
cy.reload()
cy.wait('@loadMessages')
cy.contains('button', content).should('be.visible')
cy.get('.tiptap.ProseMirror').should('not.exist')
cy.contains('button', content).click()
checkDraft()
cy.window().its('localStorage').invoke('getItem', 'replyBoxMinimized').should('eq', 'false')
cy.get('.box button[aria-label="Collapse"]').next('button').click()
cy.get('[role="dialog"]').should('be.visible')
checkDraft()
cy.get('[role="dialog"]').trigger('keydown', { key: 'Escape' })
cy.get('[role="dialog"]').should('not.exist')
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 }) => {
expect(response.statusCode).to.eq(200)
expect(request.body.message).to.contain(content)
expect(request.body.send_from).to.eq(sender)
expect(request.body.to).to.deep.eq(to)
expect(request.body.cc).to.deep.eq(copies.cc ? [copies.cc] : [])
expect(request.body.bcc).to.deep.eq(copies.bcc ? [copies.bcc] : [])
expect(request.body.attachments).to.have.members(uploads.map((upload) => upload.id))
expect(request.body.private).to.eq(false)
cy.api('GET', `/api/v1/conversations/${uuid}/messages?page=1`).then(({ body }) => {
const message = body.data.results.find((message) => message.uuid === response.body.data.uuid)
expect(message.content).to.contain(content)
expect(message.meta.to).to.deep.eq(to)
expect(message.meta.cc || []).to.deep.eq(copies.cc ? [copies.cc] : [])
expect(message.meta.bcc || []).to.deep.eq(copies.bcc ? [copies.bcc] : [])
expect(message.meta.send_from).to.eq(sender)
expect(message.attachments.map((attachment) => attachment.uuid)).to.have.members(uploads.map((upload) => upload.uuid))
})
})
cy.get('.tiptap.ProseMirror').should('have.text', '')
})
}
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'
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').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', `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()
cy.wait('@loadMessages')
checkDraft()
cy.api('POST', `/api/v1/conversations/${ccConversationUuid}/messages`, {
sender_type: 'agent', private: false, message: `<p>${update}</p>`,
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(`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('restores conversation recipients when switching between conversations', () => {
captureSend()
openConversation(ccConversationUuid)
recipientRow(/^CC$/).find('button[aria-label="Remove CC"]').click()
@@ -140,6 +140,51 @@ describe('Email inbox form', () => {
cy.contains('New support inbox').should('be.visible')
})
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()
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')
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')
})
}
})
}
it('loads saved fields once and saves values from closed sections', () => {
cy.visit(editPath)
field('name').should('have.value', 'Support')
@@ -147,4 +147,58 @@ 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, 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', '')
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')
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)
}
})
}
})
+2 -2
View File
@@ -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)
+1
View File
@@ -45,6 +45,7 @@ Cypress.Commands.add('login', () => {
}
}
)
cy.clearLocalStorage('replyBoxMinimized')
})
Cypress.Commands.add('setEmailInboxSection', (section, open) => {
+2 -1
View File
@@ -1711,9 +1711,10 @@
"team.edit": "Edit team",
"team.new": "New team",
"template.cannotDeleteBuiltInTemplate": "Cannot delete built-in template",
"template.defaultTemplateAlreadyExists": "Default template already exists",
"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}.",
"toast.apiKeyGenerated": "API key generated",
"toast.authorizationDenied": "Authorization denied",
"toast.avatarUpdated": "Avatar updated successfully",
+19
View File
@@ -226,6 +226,7 @@ type settingsStore interface {
type csatStore interface {
Create(conversationID int) (csatModels.CSATResponse, error)
Get(uuid string) (csatModels.CSATResponse, error)
UpdateResponse(uuid string, score int, feedback string, meta json.RawMessage) error
MakePublicURL(appBaseURL, uuid string) string
}
@@ -386,6 +387,7 @@ type queries struct {
// Message queries.
GetMessage *sqlx.Stmt `query:"get-message"`
GetCSATMessages *sqlx.Stmt `query:"get-csat-messages"`
GetMessages string `query:"get-messages"`
GetContactUnreadPreviewMessages *sqlx.Stmt `query:"get-contact-unread-preview-messages"`
GetOutgoingPendingMessages *sqlx.Stmt `query:"get-outgoing-pending-messages"`
@@ -1960,6 +1962,23 @@ func (m *Manager) SendCSATReply(actorUserID int, conversation models.Conversatio
return nil
}
func (m *Manager) UpdateCSATResponse(uuid string, rating int, feedback string, meta json.RawMessage) error {
if err := m.csatStore.UpdateResponse(uuid, rating, feedback, meta); err != nil {
return err
}
var messages []models.Message
if err := m.q.GetCSATMessages.Select(&messages, uuid); err != nil {
m.lo.Error("error fetching CSAT messages for update", "csat_uuid", uuid, "error", err)
return nil
}
for _, message := range messages {
message.CensorCSATContentWithStatus(true /** csatSubmitted **/, uuid, rating, feedback)
message.StripCSATUUID()
m.BroadcastMessageUpdate(message.ConversationUUID, message.UUID, map[string]any{"meta": message.Meta})
}
return nil
}
// DeleteConversation deletes a conversation.
func (m *Manager) DeleteConversation(uuid string) error {
res, err := m.q.DeleteConversation.Exec(uuid)
@@ -1,17 +1,100 @@
package conversation
import (
"encoding/json"
"fmt"
"testing"
am "github.com/abhinavxd/libredesk/internal/authz/models"
"github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/csat"
"github.com/abhinavxd/libredesk/internal/dbutil"
tmodels "github.com/abhinavxd/libredesk/internal/team/models"
"github.com/abhinavxd/libredesk/internal/testutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/abhinavxd/libredesk/internal/ws"
wsmodels "github.com/abhinavxd/libredesk/internal/ws/models"
"github.com/zerodha/logf"
)
func TestCSATSubmissionBroadcastsSavedFeedback(t *testing.T) {
db := testutil.NewDB(t, "csat_live_update")
lo := logf.New(logf.Opts{})
lang := testutil.NewI18n(t)
csats, err := csat.New(csat.Opts{DB: db, Lo: &lo, I18n: lang})
if err != nil {
t.Fatal(err)
}
hub := ws.NewHub(&lo, nil /** userStore **/)
m := &Manager{lo: &lo, i18n: lang, csatStore: csats, wsHub: hub}
if err := dbutil.ScanSQLFile("queries.sql", &m.q, db, efs); err != nil {
t.Fatal(err)
}
var contactID, inboxID int
if err := db.Get(&contactID, `INSERT INTO users (type, first_name, last_name) VALUES ('contact', 'Customer', '') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Chat', 'livechat') RETURNING id`); err != nil {
t.Fatal(err)
}
for _, rating := range []int{0, 5} {
t.Run(fmt.Sprint(rating), func(t *testing.T) {
var conv models.Conversation
if err := db.Get(&conv, `INSERT INTO conversations (contact_id, inbox_id, status_id)
VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id, uuid`, contactID, inboxID); err != nil {
t.Fatal(err)
}
response, err := csats.Create(conv.ID)
if err != nil {
t.Fatal(err)
}
var messageUUID string
if err := db.Get(&messageUUID, `INSERT INTO conversation_messages (conversation_id, type, status, sender_id, sender_type, meta)
VALUES ($1, 'outgoing', 'sent', $2, 'agent', jsonb_build_object('is_csat', true, 'csat_uuid', $3::text)) RETURNING uuid`, conv.ID, contactID, response.UUID); err != nil {
t.Fatal(err)
}
client := &ws.Client{Send: make(chan wsmodels.WSMessage, 2)}
hub.SubscribeOpenConv(client, conv.UUID)
feedback := "Helpful support. Café 日本語."
if err := m.UpdateCSATResponse(response.UUID, rating, feedback, nil /** meta **/); err != nil {
t.Fatal(err)
}
select {
case update := <-client.Send:
var payload struct {
Type string `json:"type"`
Data struct {
UUID string `json:"uuid"`
ConversationUUID string `json:"conversation_uuid"`
Meta map[string]any `json:"meta"`
} `json:"data"`
}
if err := json.Unmarshal(update.Data, &payload); err != nil {
t.Fatal(err)
}
if payload.Type != wsmodels.MessageTypeMessageUpdate || payload.Data.UUID != messageUUID || payload.Data.ConversationUUID != conv.UUID {
t.Fatalf("wrong message update: %+v", payload)
}
meta := payload.Data.Meta
if meta["csat_submitted"] != true || meta["submitted_feedback"] != feedback || meta["csat_uuid"] != nil {
t.Fatalf("incorrect or exposed CSAT metadata: %+v", meta)
}
if rating > 0 && meta["submitted_rating"] != float64(rating) {
t.Fatalf("wrong rating: %+v", meta)
}
default:
t.Fatal("no live CSAT update")
}
if err := m.UpdateCSATResponse(response.UUID, 1 /** rating **/, "duplicate", nil /** meta **/); err == nil {
t.Fatal("duplicate response accepted")
}
if len(client.Send) != 0 {
t.Fatal("rejected response broadcast an update")
}
})
}
}
func TestPreviousConversationsRespectAccessBeforeLimit(t *testing.T) {
db := testutil.NewDB(t, "previous_access")
lo := logf.New(logf.Opts{})
+7
View File
@@ -947,6 +947,13 @@ AND NOT EXISTS (
)
ORDER BY m.id
-- name: get-csat-messages
SELECT m.uuid, m.meta, c.uuid AS conversation_uuid
FROM csat_responses r
JOIN conversations c ON c.id = r.conversation_id
JOIN conversation_messages m ON m.conversation_id = c.id
WHERE r.uuid = $1 AND m.meta->>'csat_uuid' = r.uuid::text;
-- name: get-message
SELECT
m.id,
+62
View File
@@ -1,15 +1,77 @@
package media
import (
"errors"
"testing"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/media/models"
"github.com/abhinavxd/libredesk/internal/testutil"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
func TestDeletedMessageMediaUnavailable(t *testing.T) {
db := testutil.NewDB(t, "deleted_message_media")
lo := logf.New(logf.Opts{})
m := &Manager{lo: &lo, i18n: testutil.NewI18n(t), rootURL: func() string { return "https://support.example.com" }}
if err := dbutil.ScanSQLFile("queries.sql", &m.queries, db, efs); err != nil {
t.Fatal(err)
}
var contactID, inboxID, conversationID, messageID int
if err := db.Get(&contactID, `INSERT INTO users (type, first_name, last_name) VALUES ('contact', 'Customer', '') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Chat', 'livechat') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&conversationID, `INSERT INTO conversations (contact_id, inbox_id, status_id)
VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1)) RETURNING id`, contactID, inboxID); err != nil {
t.Fatal(err)
}
if err := db.Get(&messageID, `INSERT INTO conversation_messages (conversation_id, type, status, sender_id, sender_type)
VALUES ($1, 'incoming', 'sent', $2, 'contact') RETURNING id`, conversationID, contactID); err != nil {
t.Fatal(err)
}
var attachments []models.Media
for _, modelID := range []null.Int{{}, null.IntFrom(0), null.IntFrom(messageID)} {
var med models.Media
if err := db.Get(&med, `INSERT INTO media (store, filename, content_type, content_id, size, model_type, model_id, private)
VALUES ('fs', 'receipt.png', 'image/png', '', 1, 'messages', $1, false) RETURNING id, uuid`, modelID); err != nil {
t.Fatal(err)
}
attachments = append(attachments, med)
if _, err := m.Get(med.ID, "" /** uuid **/); err != nil {
t.Fatal(err)
}
if _, err := m.Get(0 /** id **/, med.UUID); err != nil {
t.Fatal(err)
}
}
if _, err := db.Exec(`DELETE FROM users WHERE id = $1`, contactID); err != nil {
t.Fatal(err)
}
for i, med := range attachments {
for _, lookup := range []func() (models.Media, error){
func() (models.Media, error) { return m.Get(med.ID, "" /** uuid **/) },
func() (models.Media, error) { return m.Get(0 /** id **/, med.UUID) },
} {
_, err := lookup()
if i < 2 {
if err != nil {
t.Fatalf("unattached draft became unavailable: %v", err)
}
continue
}
var missing envelope.Error
if !errors.As(err, &missing) || missing.ErrorType != envelope.NotFoundError {
t.Fatalf("deleted contact attachment remained available: %v", err)
}
}
}
}
func TestPendingMediaOwnership(t *testing.T) {
db := testutil.NewDB(t, "media_ownership")
lo := logf.New(logf.Opts{})
+6 -3
View File
@@ -20,9 +20,12 @@ RETURNING id;
SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta, private, uploaded_by
FROM media
WHERE
($1 > 0 AND id = $1)
OR
($2 != '' AND uuid = NULLIF($2, '')::uuid)
(($1 > 0 AND id = $1) OR ($2 != '' AND uuid = NULLIF($2, '')::uuid))
AND (
model_type IS DISTINCT FROM 'messages'
OR COALESCE(model_id, 0) = 0
OR EXISTS (SELECT 1 FROM conversation_messages cm WHERE cm.id = media.model_id)
);
-- name: get-media-by-uuid
SELECT id, created_at, updated_at, "uuid", store, filename, content_type, content_id, model_id, model_type, disposition, "size", meta, private, uploaded_by
+10 -10
View File
@@ -67,7 +67,7 @@ WITH latest_applied AS (
resolution_met_at, resolution_breached_at
FROM applied_slas
WHERE created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
ELSE NOW() - INTERVAL '%d days'
END
ORDER BY conversation_id, created_at DESC, id DESC
@@ -148,7 +148,7 @@ next_response AS (
sla_events
WHERE
created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
ELSE NOW() - INTERVAL '%d days'
END
AND type = 'next_response'
@@ -189,13 +189,13 @@ WITH new_conversations AS (
FROM
(
SELECT
TO_CHAR(created_at :: date, 'YYYY-MM-DD') AS date,
TO_CHAR(created_at AT TIME ZONE $1, 'YYYY-MM-DD') AS date,
COUNT(*) AS count
FROM
conversations c
WHERE
c.created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
ELSE NOW() - INTERVAL '%d days'
END
GROUP BY
@@ -210,13 +210,13 @@ resolved_conversations AS (
FROM
(
SELECT
TO_CHAR(resolved_at :: date, 'YYYY-MM-DD') AS date,
TO_CHAR(resolved_at AT TIME ZONE $1, 'YYYY-MM-DD') AS date,
COUNT(*) AS count
FROM
conversations c
WHERE
c.resolved_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
ELSE NOW() - INTERVAL '%d days'
END
GROUP BY
@@ -263,7 +263,7 @@ FROM
csat_responses
WHERE
created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
ELSE NOW() - INTERVAL '%d days'
END;
@@ -281,7 +281,7 @@ WITH per_conversation AS (
AND private = false
AND (type = 'incoming' OR status = 'sent')
AND created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
ELSE NOW() - INTERVAL '%d days'
END
GROUP BY
@@ -321,7 +321,7 @@ WITH tag_counts AS (
LEFT JOIN conversation_tags ct ON t.id = ct.tag_id
LEFT JOIN conversations c ON ct.conversation_id = c.id
AND c.created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
ELSE NOW() - INTERVAL '%d days'
END
GROUP BY
@@ -343,7 +343,7 @@ tagging AS (
conversations c
WHERE
c.created_at >= CASE
WHEN %d = 0 THEN CURRENT_DATE
WHEN %d = 0 THEN DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1
ELSE NOW() - INTERVAL '%d days'
END
)
+11 -10
View File
@@ -11,6 +11,7 @@ import (
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/report/models"
"github.com/abhinavxd/libredesk/internal/stringutil"
"github.com/jmoiron/sqlx"
"github.com/knadh/go-i18n"
"github.com/zerodha/logf"
@@ -90,7 +91,7 @@ func (m *Manager) GetOverViewCounts() (json.RawMessage, error) {
}
// GetOverviewSLA returns overview SLA data
func (m *Manager) GetOverviewSLA(days int) (json.RawMessage, error) {
func (m *Manager) GetOverviewSLA(days int, timezone string) (json.RawMessage, error) {
days = clampDays(days)
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
ReadOnly: true,
@@ -104,7 +105,7 @@ func (m *Manager) GetOverviewSLA(days int) (json.RawMessage, error) {
var result models.OverviewSLA
// Format query with days parameter for both CTEs
query := fmt.Sprintf(m.q.GetOverviewSLA, days, days, days, days)
if err := tx.Get(&result, query); err != nil {
if err := tx.Get(&result, query, stringutil.NormalizeTimezone(timezone)); err != nil {
m.lo.Error("error fetching overview SLA data", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
@@ -124,7 +125,7 @@ func (m *Manager) GetOverviewSLA(days int) (json.RawMessage, error) {
}
// GetOverviewChart returns overview chart data
func (m *Manager) GetOverviewChart(days int) (json.RawMessage, error) {
func (m *Manager) GetOverviewChart(days int, timezone string) (json.RawMessage, error) {
days = clampDays(days)
var stats = json.RawMessage{}
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
@@ -137,7 +138,7 @@ func (m *Manager) GetOverviewChart(days int) (json.RawMessage, error) {
defer tx.Rollback()
query := fmt.Sprintf(m.q.GetOverviewCharts, days, days, days, days)
if err := tx.Get(&stats, query); err != nil {
if err := tx.Get(&stats, query, stringutil.NormalizeTimezone(timezone)); err != nil {
m.lo.Error("error fetching overview charts", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
@@ -145,7 +146,7 @@ func (m *Manager) GetOverviewChart(days int) (json.RawMessage, error) {
}
// GetOverviewCSAT returns CSAT metrics for the overview dashboard
func (m *Manager) GetOverviewCSAT(days int) (json.RawMessage, error) {
func (m *Manager) GetOverviewCSAT(days int, timezone string) (json.RawMessage, error) {
days = clampDays(days)
var stats = json.RawMessage{}
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
@@ -158,7 +159,7 @@ func (m *Manager) GetOverviewCSAT(days int) (json.RawMessage, error) {
defer tx.Rollback()
query := fmt.Sprintf(m.q.GetOverviewCSAT, days, days)
if err := tx.Get(&stats, query); err != nil {
if err := tx.Get(&stats, query, stringutil.NormalizeTimezone(timezone)); err != nil {
m.lo.Error("error fetching overview CSAT", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
@@ -166,7 +167,7 @@ func (m *Manager) GetOverviewCSAT(days int) (json.RawMessage, error) {
}
// GetOverviewMessageVolume returns message volume metrics for the overview dashboard
func (m *Manager) GetOverviewMessageVolume(days int) (json.RawMessage, error) {
func (m *Manager) GetOverviewMessageVolume(days int, timezone string) (json.RawMessage, error) {
days = clampDays(days)
var stats = json.RawMessage{}
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
@@ -179,7 +180,7 @@ func (m *Manager) GetOverviewMessageVolume(days int) (json.RawMessage, error) {
defer tx.Rollback()
query := fmt.Sprintf(m.q.GetOverviewMessageVolume, days, days)
if err := tx.Get(&stats, query); err != nil {
if err := tx.Get(&stats, query, stringutil.NormalizeTimezone(timezone)); err != nil {
m.lo.Error("error fetching overview message volume", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
@@ -187,7 +188,7 @@ func (m *Manager) GetOverviewMessageVolume(days int) (json.RawMessage, error) {
}
// GetOverviewTagDistribution returns tag distribution metrics for the overview dashboard
func (m *Manager) GetOverviewTagDistribution(days int) (json.RawMessage, error) {
func (m *Manager) GetOverviewTagDistribution(days int, timezone string) (json.RawMessage, error) {
days = clampDays(days)
var stats = json.RawMessage{}
tx, err := m.db.BeginTxx(context.Background(), &sql.TxOptions{
@@ -200,7 +201,7 @@ func (m *Manager) GetOverviewTagDistribution(days int) (json.RawMessage, error)
defer tx.Rollback()
query := fmt.Sprintf(m.q.GetOverviewTagDistribution, days, days, days, days)
if err := tx.Get(&stats, query); err != nil {
if err := tx.Get(&stats, query, stringutil.NormalizeTimezone(timezone)); err != nil {
m.lo.Error("error fetching overview tag distribution", "error", err)
return nil, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
+118
View File
@@ -0,0 +1,118 @@
package report
import (
"encoding/json"
"testing"
"time"
"github.com/abhinavxd/libredesk/internal/testutil"
"github.com/zerodha/logf"
)
func TestOverviewDatesUseWorkspaceTimezone(t *testing.T) {
db := testutil.NewDB(t, "report_timezone")
lo := logf.New(logf.Opts{})
m, err := New(Opts{DB: db, Lo: &lo, I18n: testutil.NewI18n(t)})
if err != nil {
t.Fatal(err)
}
var contactID, inboxID int
if err := db.Get(&contactID, `INSERT INTO users (type, first_name, last_name) VALUES ('contact', 'Report', '') RETURNING id`); err != nil {
t.Fatal(err)
}
if err := db.Get(&inboxID, `INSERT INTO inboxes (name, channel) VALUES ('Report', 'livechat') RETURNING id`); err != nil {
t.Fatal(err)
}
base := time.Now().UTC().Truncate(24 * time.Hour).Add(-24 * time.Hour)
instants := []time.Time{base.Add(30 * time.Minute), base.Add(23*time.Hour + 30*time.Minute)}
for _, instant := range instants {
if _, err := db.Exec(`INSERT INTO conversations (contact_id, inbox_id, status_id, created_at, resolved_at)
VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1), $3, $3)`, contactID, inboxID, instant); err != nil {
t.Fatal(err)
}
}
for _, timezone := range []string{"UTC", "Asia/Kathmandu", "America/Los_Angeles", "", "invalid/zone", "Local"} {
t.Run(timezone, func(t *testing.T) {
loc := time.UTC
if timezone == "Asia/Kathmandu" || timezone == "America/Los_Angeles" {
loc, err = time.LoadLocation(timezone)
if err != nil {
t.Fatal(err)
}
}
want := map[string]int{}
for _, instant := range instants {
want[instant.In(loc).Format(time.DateOnly)]++
}
raw, err := m.GetOverviewChart(7 /** days **/, timezone)
if err != nil {
t.Fatal(err)
}
var charts map[string][]struct {
Date string `json:"date"`
Count int `json:"count"`
}
if err := json.Unmarshal(raw, &charts); err != nil {
t.Fatal(err)
}
for _, name := range []string{"new_conversations", "resolved_conversations"} {
if len(charts[name]) != len(want) {
t.Fatalf("%s dates = %v, want %v", name, charts[name], want)
}
for _, row := range charts[name] {
if row.Count != want[row.Date] {
t.Fatalf("%s row = %+v, want %v", name, row, want)
}
}
}
})
}
if _, err := db.Exec(`DELETE FROM conversations`); err != nil {
t.Fatal(err)
}
timezone := "Asia/Kathmandu"
var midnight time.Time
if err := db.Get(&midnight, `SELECT DATE_TRUNC('day', NOW() AT TIME ZONE $1) AT TIME ZONE $1`, timezone); err != nil {
t.Fatal(err)
}
for _, instant := range []time.Time{midnight.Add(-time.Minute), midnight.Add(time.Minute)} {
var conversationID int
if err := db.Get(&conversationID, `INSERT INTO conversations (contact_id, inbox_id, status_id, created_at)
VALUES ($1, $2, (SELECT id FROM conversation_statuses LIMIT 1), $3) RETURNING id`, contactID, inboxID, instant); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO csat_responses (conversation_id, rating, created_at) VALUES ($1, 5, $2)`, conversationID, instant); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO conversation_messages (conversation_id, type, status, sender_id, sender_type, created_at)
VALUES ($1, 'incoming', 'sent', $2, 'contact', $3)`, conversationID, contactID, instant); err != nil {
t.Fatal(err)
}
}
for _, tc := range []struct {
name string
get func(int, string) (json.RawMessage, error)
key string
want float64
}{
{"csat", m.GetOverviewCSAT, "total_responses", 1},
{"messages", m.GetOverviewMessageVolume, "total_messages", 1},
{"tags", m.GetOverviewTagDistribution, "untagged_conversations", 1},
{"sla", m.GetOverviewSLA, "first_response_met_count", 0},
} {
t.Run(tc.name, func(t *testing.T) {
raw, err := tc.get(0 /** days **/, timezone)
if err != nil {
t.Fatal(err)
}
var result map[string]any
if err := json.Unmarshal(raw, &result); err != nil {
t.Fatal(err)
}
if result[tc.key] != tc.want {
t.Fatalf("%s = %v, want %v", tc.key, result[tc.key], tc.want)
}
})
}
}
+54 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"text/template"
"text/template/parse"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
@@ -74,7 +75,15 @@ func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, err
return "", fmt.Errorf("parsing base template: %w", err)
}
contentTemplate, err := template.New(TmplContent).Funcs(m.funcMap).Parse(content)
contentCount := 0
trackContent := template.FuncMap{
"_libredeskTrackContent": func() string {
contentCount++
return ""
},
}
baseTemplate.Funcs(trackContent)
contentTemplate, err := template.New(TmplContent).Funcs(m.funcMap).Funcs(trackContent).Parse(`{{_libredeskTrackContent}}` + content)
if err != nil {
return "", fmt.Errorf("parsing content template: %w", err)
}
@@ -89,6 +98,9 @@ func (m *Manager) RenderEmailWithTemplate(data any, content string) (string, err
return "", fmt.Errorf("executing base template: %w", err)
}
if contentCount == 0 {
return "", fmt.Errorf("outgoing template did not render the reply")
}
return rendered.String(), nil
}
@@ -215,3 +227,44 @@ func personData(person umodels.User) map[string]any {
"Email": person.Email.String,
}
}
// 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 {
return nil, err
}
count := 0
for _, part := range tmpl.Templates() {
count += countContentTemplates(part.Tree.Root)
}
if count == 0 {
return nil, fmt.Errorf("outgoing template contains no reply template")
}
return tmpl, nil
}
func countContentTemplates(node parse.Node) int {
switch node := node.(type) {
case *parse.TemplateNode:
if node.Name == TmplContent {
return 1
}
case *parse.ListNode:
if node == nil {
return 0
}
count := 0
for _, child := range node.Nodes {
count += countContentTemplates(child)
}
return count
case *parse.IfNode:
return countContentTemplates(node.List) + countContentTemplates(node.ElseList)
case *parse.RangeNode:
return countContentTemplates(node.List) + countContentTemplates(node.ElseList)
case *parse.WithNode:
return countContentTemplates(node.List) + countContentTemplates(node.ElseList)
}
return 0
}
+26 -1
View File
@@ -9,6 +9,7 @@ import (
"html/template"
"strings"
"sync"
texttemplate "text/template"
"github.com/abhinavxd/libredesk/internal/dbutil"
"github.com/abhinavxd/libredesk/internal/envelope"
@@ -65,8 +66,17 @@ 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
}
var result models.Template
if err := m.q.UpdateTemplate.Get(&result, id, t.Name, t.Body, t.IsDefault, t.Subject, t.Type); err != nil {
if dbutil.IsUniqueViolationError(err) && t.IsDefault {
return models.Template{}, envelope.NewError(envelope.ConflictError, m.i18n.T("template.defaultTemplateAlreadyExists"), nil /** data **/)
}
m.lo.Error("error updating template", "error", err)
return models.Template{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
}
@@ -78,10 +88,13 @@ func (m *Manager) Create(t models.Template) (models.Template, error) {
if t.IsDefault {
t.Type = TypeEmailOutgoing
}
if err := m.validateOutgoingTemplate(t); err != nil {
return models.Template{}, err
}
var result models.Template
if err := m.q.InsertTemplate.Get(&result, t.Name, t.Body, t.IsDefault, t.Subject, t.Type); err != nil {
if dbutil.IsUniqueViolationError(err) && t.IsDefault {
return models.Template{}, envelope.NewError(envelope.GeneralError, m.i18n.T("template.defaultTemplateAlreadyExists"), nil)
return models.Template{}, envelope.NewError(envelope.ConflictError, m.i18n.T("template.defaultTemplateAlreadyExists"), nil /** data **/)
}
m.lo.Error("error inserting template", "error", err)
return models.Template{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil)
@@ -192,3 +205,15 @@ func (m *Manager) Reload(webTpls, tpls *template.Template, funcMap template.Func
m.funcMap = funcMap
return nil
}
func (m *Manager) validateOutgoingTemplate(t models.Template) error {
if t.Type != TypeEmailOutgoing && !t.IsDefault {
return nil
}
m.mutex.RLock()
defer m.mutex.RUnlock()
if _, err := parseOutgoingTemplate(t.Body, texttemplate.FuncMap(m.funcMap)); err != nil {
return envelope.NewError(envelope.InputError, m.i18n.Ts("template.invalidOutgoingTemplate", "content", `{{ template "content" . }}`), nil /** data **/)
}
return nil
}
+225
View File
@@ -1,6 +1,7 @@
package template
import (
"errors"
"fmt"
"html/template"
"os"
@@ -9,12 +10,236 @@ import (
"testing"
cmodels "github.com/abhinavxd/libredesk/internal/conversation/models"
"github.com/abhinavxd/libredesk/internal/envelope"
"github.com/abhinavxd/libredesk/internal/template/models"
"github.com/abhinavxd/libredesk/internal/testutil"
umodels "github.com/abhinavxd/libredesk/internal/user/models"
"github.com/volatiletech/null/v9"
"github.com/zerodha/logf"
)
var notificationSeedPattern = regexp.MustCompile(`(?s)VALUES\s*\(\s*'email_notification'::template_type,\s*'((?:''|[^'])*)'\s*,\s*false,\s*'([^']+)'`)
func TestDefaultTemplateConflict(t *testing.T) {
db := testutil.NewDB(t, "default_template_conflict")
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)
}
first, err := m.Create(models.Template{Name: "First", Type: TypeEmailOutgoing, IsDefault: true, Body: `{{ template "content" . }}`})
if err != nil {
t.Fatal(err)
}
second, err := m.Create(models.Template{Name: "Second", Type: TypeEmailOutgoing, Body: `{{ template "content" . }}`})
if err != nil {
t.Fatal(err)
}
second.IsDefault = true
for _, save := range []func() (models.Template, error){
func() (models.Template, error) { return m.Create(second) },
func() (models.Template, error) { return m.Update(second.ID, second) },
} {
_, err := save()
var conflict envelope.Error
if !errors.As(err, &conflict) || conflict.ErrorType != envelope.ConflictError {
t.Fatalf("expected an actionable conflict, got %v", err)
}
current, err := m.getDefaultOutgoingEmailTemplate()
if err != nil || current.ID != first.ID {
t.Fatalf("default changed after rejected save: %+v, %v", current, err)
}
}
first.IsDefault = false
if _, err := m.Update(first.ID, first); err != nil {
t.Fatal(err)
}
if _, err := m.Update(second.ID, second); err != nil {
t.Fatal(err)
}
current, err := m.getDefaultOutgoingEmailTemplate()
if err != nil || current.ID != second.ID {
t.Fatalf("default did not switch: %+v, %v", current, err)
}
}
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 = "<p>No reply placeholder</p>"
if _, err := m.Update(saved.ID, candidate); err == nil {
t.Fatalf("accepted default type %q without a reply", typ)
}
}
saved.Type = TypeEmailNotification
saved.Body = "<p>Notification</p>"
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 {
name string
body string
ok bool
}{
{"empty", "", false},
{"missing", "<p>Header</p>", false},
{"single", `{{ template "content" . }}`, true},
{"duplicate", `{{ template "content" . }}{{ template "content" . }}`, true},
{"triple", `{{ template "content" . }}{{ template "content" . }}{{ template "content" . }}`, true},
{"trimmed duplicate", `{{- template "content" . -}}<p>Footer</p>{{- 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" . }}`, 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 }}`, 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 }}`, 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" . }}`, 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},
} {
t.Run(tc.name, func(t *testing.T) {
err := m.validateOutgoingTemplate(models.Template{Type: TypeEmailOutgoing, Body: tc.body})
if (err == nil) != tc.ok {
t.Fatalf("validation error = %v, want valid = %v", err, tc.ok)
}
})
}
}
func TestOutgoingTemplatePreservesReply(t *testing.T) {
db := testutil.NewDB(t, "outgoing_template_content")
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)
}
valid := models.Template{Name: "Customer layout", Type: TypeEmailOutgoing, IsDefault: true, Body: `<header>Support</header>{{ template "content" . }}<footer>Thank you</footer>`}
saved, err := m.Create(valid)
if err != nil {
t.Fatal(err)
}
for _, body := range []string{
"",
"<p>Support header</p>",
`{{/* template "content" . */}}`,
`{{ template "content" .`,
} {
t.Run("reject "+body, func(t *testing.T) {
candidate := valid
candidate.Body = body
candidate.IsDefault = false
if _, err := m.Create(candidate); err == nil {
t.Fatal("created an invalid outgoing template")
}
if _, err := m.Update(saved.ID, candidate); err == nil {
t.Fatal("updated to an invalid outgoing template")
}
stored, err := m.Get(saved.ID)
if err != nil || stored.Body != valid.Body || !stored.IsDefault {
t.Fatalf("rejected edit changed the saved template: %+v, %v", stored, err)
}
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM templates WHERE type = $1`, TypeEmailOutgoing); err != nil || count != 1 {
t.Fatalf("rejected create changed template count: %d, %v", count, err)
}
})
}
stored, err := m.Get(saved.ID)
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 }}<p>Hello</p>{{ 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
count int
}{
{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},
{`<p>Previously saved layout without a reply</p>`, 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 {
t.Fatal(err)
}
const reply = "<p>Your receipt is ready. Café 日本語.</p>"
got, err := m.RenderEmailWithTemplate(tc.data, reply)
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)
}
})
}
}
func TestRenderNotificationEmailUsesSystemLayout(t *testing.T) {
m := emailTemplateManager(t)
var header strings.Builder
+13 -1
View File
@@ -226,8 +226,12 @@
createElements () {
const branding = this.branding();
this.toggleButton = document.createElement('div');
this.toggleButton = document.createElement('button');
this.toggleButton.type = 'button';
this.toggleButton.style.cssText = `
all: revert;
border: 0;
padding: 0;
position: fixed;
cursor: pointer;
z-index: 9999;
@@ -243,6 +247,7 @@
`;
this.iconContainer = document.createElement('div');
this.iconContainer.setAttribute('aria-hidden', 'true');
this.iconContainer.style.cssText = `
width: 100%;
height: 100%;
@@ -255,6 +260,7 @@
const iconTransition = `opacity ${this.OPEN_MS}ms ${this.EASE_OUT}, transform ${this.OPEN_MS}ms ${this.EASE_OUT}`;
this.defaultIcon = document.createElement('img');
this.defaultIcon.alt = '';
this.defaultIcon.src = branding.launcher?.logo_url || (this.config.baseURL + DEFAULT_LAUNCHER_LOGO_PATH);
this.defaultIcon.style.transition = iconTransition;
this.styleLauncherIcon();
@@ -320,6 +326,8 @@
this.widgetButtonWrapper = widgetButtonWrapper;
this.iframe = document.createElement('iframe');
this.iframe.id = `libredesk-widget-${this.config.inboxID}`;
this.toggleButton.setAttribute('aria-controls', this.iframe.id);
this.iframe.src = `${this.config.baseURL}/widget?inbox_id=${encodeURIComponent(this.config.inboxID)}&parent_origin=${encodeURIComponent(window.location.origin)}` +
(this.language ? `&lang=${encodeURIComponent(this.language)}` : '');
this.iframeLanguage = this.language;
@@ -498,6 +506,7 @@
case 'REPLY_PREVIEWS':
if (event.data.previews?.length && this.campaignData) this.dropCampaign(this.campaignData.id);
this.previewData = event.data;
this.setLauncherIcon(this.isChatVisible);
this.renderPreviews();
break;
case 'VUE_APP_READY':
@@ -668,6 +677,7 @@
if (this.unreadCount > 0) this.showUnreadBadge(this.unreadCount);
this.postToIframe({ type: 'WIDGET_CLOSED' });
if (document.activeElement === iframe) this.toggleButton.focus();
if (this._onHideCallback) this._onHideCallback();
}
@@ -680,6 +690,8 @@
}
setLauncherIcon (open) {
this.toggleButton.setAttribute('aria-expanded', String(open));
this.toggleButton.setAttribute('aria-label', this.previewData?.labels?.[open ? 'closeChat' : 'openChat'] || 'libredesk');
const hidden = (deg) => this.reducedMotion ? 'none' : `rotate(${deg}deg) scale(0.9)`;
if (this.defaultIcon) {
this.defaultIcon.style.opacity = open ? '0' : '1';