From 5733b822e3a384d5e237446cb775acd04371b228 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Wed, 22 Jul 2026 17:36:36 +0530 Subject: [PATCH] fix: guard widget email-transcript button against repeat clicks (#15095) ## Description The widget email-transcript button (`ChatFooter.vue`) had no client-side guard. Its visibility depends only on whether the contact has an email, and the click handler fired a request on every click with no in-flight lock, no disabled state, and no post-send handling. A user could therefore trigger a large number of duplicate transcript emails from a single conversation just by clicking repeatedly. This adds a re-entry guard in the handler, disables the button while a send is in flight, and applies a short cooldown (15s) after a successful send. Normal use is unaffected: the button sends once, shows the success toast, then briefly disables and automatically re-enables so a genuine later re-request still works. On failure the button stays enabled so the user can retry immediately. The cooldown timer is cleared on unmount. Using a timed cooldown (rather than a permanent post-send lock) also avoids the button getting stuck disabled if a resolved conversation is reopened and later re-resolved. This is the client-side complement to the server-side rate limit added in #15085. Fixes https://linear.app/chatwoot/issue/CW-7640 --- .../widget/components/ChatFooter.vue | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/app/javascript/widget/components/ChatFooter.vue b/app/javascript/widget/components/ChatFooter.vue index c85727a2b..2cad4d177 100755 --- a/app/javascript/widget/components/ChatFooter.vue +++ b/app/javascript/widget/components/ChatFooter.vue @@ -11,6 +11,8 @@ import { IFrameHelper } from '../helpers/utils'; import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents'; import { emitter } from 'shared/helpers/mitt'; +const TRANSCRIPT_COOLDOWN_MS = 15000; + export default { components: { ChatInputWrap, @@ -24,6 +26,9 @@ export default { data() { return { inReplyTo: null, + isSendingTranscript: false, + transcriptCooldown: false, + transcriptCooldownTimer: null, }; }, computed: { @@ -57,6 +62,9 @@ export default { mounted() { emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo); }, + beforeUnmount() { + clearTimeout(this.transcriptCooldownTimer); + }, methods: { ...mapActions('conversation', ['sendMessage', 'sendAttachment']), ...mapActions('conversationAttributes', ['getAttributes']), @@ -90,19 +98,35 @@ export default { toggleReplyTo(message) { this.inReplyTo = message; }, + startTranscriptCooldown() { + this.transcriptCooldown = true; + clearTimeout(this.transcriptCooldownTimer); + this.transcriptCooldownTimer = setTimeout(() => { + this.transcriptCooldown = false; + }, TRANSCRIPT_COOLDOWN_MS); + }, async sendTranscript() { - if (this.hasEmail) { - try { - await sendEmailTranscript(); - emitter.emit(BUS_EVENTS.SHOW_ALERT, { - message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'), - type: 'success', - }); - } catch (error) { - emitter.$emit(BUS_EVENTS.SHOW_ALERT, { - message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'), - }); - } + if ( + !this.hasEmail || + this.isSendingTranscript || + this.transcriptCooldown + ) { + return; + } + this.isSendingTranscript = true; + try { + await sendEmailTranscript(); + this.startTranscriptCooldown(); + emitter.emit(BUS_EVENTS.SHOW_ALERT, { + message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'), + type: 'success', + }); + } catch (error) { + emitter.emit(BUS_EVENTS.SHOW_ALERT, { + message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'), + }); + } finally { + this.isSendingTranscript = false; } }, }, @@ -144,6 +168,7 @@ export default { v-if="showEmailTranscriptButton" type="clear" class="font-normal" + :disabled="isSendingTranscript || transcriptCooldown" @click="sendTranscript" > {{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}