From 06b36b74fd537a2ed37ce60ec79b6a227ffe0073 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Tue, 21 Apr 2026 20:24:19 +0530 Subject: [PATCH] Add prompt-to-tag-on-reply inbox setting --- cmd/oauth.go | 13 ++++--- cmd/upgrade.go | 1 + .../features/admin/inbox/EmailInboxForm.vue | 16 ++++++++ .../admin/inbox/LivechatInboxForm.vue | 12 ++++++ .../src/features/admin/inbox/formSchema.js | 1 + .../admin/inbox/livechatFormSchema.js | 1 + .../src/features/conversation/ReplyBox.vue | 37 ++++++++++++++++++- .../main/src/views/admin/inbox/NewInbox.vue | 1 + i18n/en.json | 4 ++ internal/inbox/inbox.go | 4 +- internal/inbox/models/models.go | 1 + internal/inbox/queries.sql | 14 +++---- internal/migrations/v2.1.0.go | 19 ++++++++++ schema.sql | 1 + 14 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 internal/migrations/v2.1.0.go diff --git a/cmd/oauth.go b/cmd/oauth.go index 1c6698a5..92c27454 100644 --- a/cmd/oauth.go +++ b/cmd/oauth.go @@ -326,12 +326,13 @@ func handleOAuthCallback(r *fastglue.Request) error { // Create inbox newInbox := imodels.Inbox{ - Name: fmt.Sprintf("%s Inbox", userEmail), - From: userEmail, - Channel: inbox.ChannelEmail, - Enabled: true, - CSATEnabled: false, - Config: json.RawMessage(configJSON), + Name: fmt.Sprintf("%s Inbox", userEmail), + From: userEmail, + Channel: inbox.ChannelEmail, + Enabled: true, + CSATEnabled: false, + PromptTagsOnReply: false, + Config: json.RawMessage(configJSON), } createdInbox, err := app.inbox.Create(newInbox) diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 7514d9eb..35aa84f5 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -41,6 +41,7 @@ var migList = []migFunc{ {"v0.10.0", migrations.V0_10_0}, {"v1.0.1", migrations.V1_0_1}, {"v2.0.0", migrations.V2_0_0}, + {"v2.1.0", migrations.V2_1_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/frontend/apps/main/src/features/admin/inbox/EmailInboxForm.vue b/frontend/apps/main/src/features/admin/inbox/EmailInboxForm.vue index 2ec450ef..c5aaef5e 100644 --- a/frontend/apps/main/src/features/admin/inbox/EmailInboxForm.vue +++ b/frontend/apps/main/src/features/admin/inbox/EmailInboxForm.vue @@ -70,6 +70,21 @@ + + + + + + @@ -750,6 +765,7 @@ const form = useForm({ from: '', enabled: true, csat_enabled: false, + prompt_tags_on_reply: false, enable_plus_addressing: true, auth_type: AUTH_TYPE_PASSWORD, imap: { diff --git a/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue b/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue index abf0791a..ec7ceb33 100644 --- a/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue +++ b/frontend/apps/main/src/features/admin/inbox/LivechatInboxForm.vue @@ -45,6 +45,17 @@

+ + + + + + {{ $t('globals.terms.name') }} @@ -1112,6 +1123,7 @@ const form = useForm({ enabled: true, secret: '', csat_enabled: false, + prompt_tags_on_reply: false, linked_email_inbox_id: null, config: { brand_name: '', diff --git a/frontend/apps/main/src/features/admin/inbox/formSchema.js b/frontend/apps/main/src/features/admin/inbox/formSchema.js index cbd7f59d..7ac5b072 100644 --- a/frontend/apps/main/src/features/admin/inbox/formSchema.js +++ b/frontend/apps/main/src/features/admin/inbox/formSchema.js @@ -7,6 +7,7 @@ export const createFormSchema = (t) => z.object({ from: z.string().min(1, t('globals.messages.required')), enabled: z.boolean().optional(), csat_enabled: z.boolean().optional(), + prompt_tags_on_reply: z.boolean().optional(), enable_plus_addressing: z.boolean().optional(), auth_type: z.enum([AUTH_TYPE_PASSWORD, AUTH_TYPE_OAUTH2]), oauth: z.object({ diff --git a/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js b/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js index eeb14926..ff0d615a 100644 --- a/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js +++ b/frontend/apps/main/src/features/admin/inbox/livechatFormSchema.js @@ -14,6 +14,7 @@ export const createFormSchema = (t) => z.object({ name: z.string().min(1, { message: t('globals.messages.required') }), enabled: z.boolean(), csat_enabled: z.boolean(), + prompt_tags_on_reply: z.boolean(), secret: z.string().nullable().optional(), linked_email_inbox_id: z.number().nullable().optional(), config: z.object({ diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue index 15f3021b..9664f974 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBox.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue @@ -13,7 +13,24 @@ {{ $t('globals.messages.cancel') }} - {{ + {{ + $t('replyBox.sendAnyway') + }} + + + + + + + + {{ $t('replyBox.missingTagsTitle') }} + + {{ $t('replyBox.missingTagsDescription') }} + + + + {{ $t('globals.messages.cancel') }} + {{ $t('replyBox.sendAnyway') }} @@ -138,6 +155,7 @@ import { useDraftManager } from '@main/composables/useDraftManager' import api from '@main/api' import { useI18n } from 'vue-i18n' import { useConversationStore } from '@main/stores/conversation' +import { useInboxStore } from '@main/stores/inbox' import { AlertDialog, AlertDialogAction, @@ -181,6 +199,7 @@ const formSchema = toTypedSchema( const { t } = useI18n() const conversationStore = useConversationStore() +const inboxStore = useInboxStore() const emitter = useEmitter() const userStore = useUserStore() @@ -221,6 +240,7 @@ const emailErrors = ref([]) const aiPrompts = ref([]) const replyBoxContentRef = ref(null) const showContactEmailWarning = ref(false) +const showMissingTagsWarning = ref(false) const mentions = ref([]) /** @@ -297,7 +317,7 @@ const hasTextContent = computed(() => { /** * Processes the send action. */ -const processSend = async (skipContactEmailCheck = false) => { +const processSend = async (skipContactEmailCheck = false, skipMissingTagsCheck = false) => { let hasMessageSendingErrored = false isEditorFullscreen.value = false @@ -305,6 +325,19 @@ const processSend = async (skipContactEmailCheck = false) => { const convUUID = conversationStore.current.uuid const isPrivate = messageType.value === 'private_note' + const currentInbox = inboxStore.inboxes.find( + (i) => i.id === conversationStore.current.inbox_id + ) + if ( + !isPrivate && + !skipMissingTagsCheck && + currentInbox?.prompt_tags_on_reply && + !(conversationStore.current.tags?.length > 0) + ) { + showMissingTagsWarning.value = true + return + } + if (!isPrivate && conversationStore.current.inbox_channel === 'email') { // Require at least one recipient in `to`. if (!to.value.trim()) { diff --git a/frontend/apps/main/src/views/admin/inbox/NewInbox.vue b/frontend/apps/main/src/views/admin/inbox/NewInbox.vue index d3ca41ed..867ecd98 100644 --- a/frontend/apps/main/src/views/admin/inbox/NewInbox.vue +++ b/frontend/apps/main/src/views/admin/inbox/NewInbox.vue @@ -134,6 +134,7 @@ const submitLiveChatForm = (values) => { channel: 'livechat', enabled: values.enabled ?? true, csat_enabled: values.csat_enabled ?? false, + prompt_tags_on_reply: values.prompt_tags_on_reply ?? false, config: values.config } createInbox(payload) diff --git a/i18n/en.json b/i18n/en.json index 58ae7c04..ff4e366e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -128,6 +128,8 @@ "admin.inbox.csatSurveys.description_3": "CSAT surveys are only sent once per conversation.", "admin.inbox.enablePlusAddressing": "Enable plus addressing", "admin.inbox.enablePlusAddressing.description": "Improves conversation threading but requires provider support (e.g., Gmail, Microsoft 365).", + "admin.inbox.promptTagsOnReply": "Prompt to tag before replying", + "admin.inbox.promptTagsOnReply.description": "Warn agents before they send a reply on a conversation that has no tags. Agents can still send without tagging by confirming the prompt.", "admin.inbox.enabled.description": "Toggle scanning inbox and sending out messages.", "admin.inbox.fromEmailAddress.description": "From email address for your inbox. e.g. My inbox ", "admin.inbox.fromEmailAddress.placeholder": "My inbox ", @@ -905,6 +907,8 @@ "replyBox.contactEmailMissingDescription": "The contact's email ({email}) is not included in to, cc, or bcc. The contact won't receive this reply.", "replyBox.emailAddresess": "Email addresses separated by comma", "replyBox.invalidEmailsIn": "Invalid email(s) in", + "replyBox.missingTagsDescription": "This conversation has no tags. Tagging helps with reporting and triage. You can still send without tagging.", + "replyBox.missingTagsTitle": "No tags on this conversation", "replyBox.removeBCC": "Remove BCC", "replyBox.sendAnyway": "Send anyway", "replyBox.toRequired": "At least one recipient is required in the To field.", diff --git a/internal/inbox/inbox.go b/internal/inbox/inbox.go index e5e8b2ea..8e146027 100644 --- a/internal/inbox/inbox.go +++ b/internal/inbox/inbox.go @@ -250,7 +250,7 @@ func (m *Manager) Create(inbox imodels.Inbox) (imodels.Inbox, error) { } var createdInbox imodels.Inbox - if err := m.queries.InsertInbox.Get(&createdInbox, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.Secret, inbox.LinkedEmailInboxID); err != nil { + if err := m.queries.InsertInbox.Get(&createdInbox, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.PromptTagsOnReply, inbox.Secret, inbox.LinkedEmailInboxID); err != nil { m.lo.Error("error creating inbox", "error", err) return imodels.Inbox{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } @@ -424,7 +424,7 @@ func (m *Manager) Update(id int, inbox imodels.Inbox) (imodels.Inbox, error) { // Update the inbox in the DB. var updatedInbox imodels.Inbox - if err := m.queries.Update.Get(&updatedInbox, id, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.Enabled, inbox.Secret, inbox.LinkedEmailInboxID); err != nil { + if err := m.queries.Update.Get(&updatedInbox, id, inbox.Channel, encryptedConfig, inbox.Name, inbox.From, inbox.CSATEnabled, inbox.PromptTagsOnReply, inbox.Enabled, inbox.Secret, inbox.LinkedEmailInboxID); err != nil { m.lo.Error("error updating inbox", "error", err) return imodels.Inbox{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } diff --git a/internal/inbox/models/models.go b/internal/inbox/models/models.go index 7d81a788..96af15e7 100644 --- a/internal/inbox/models/models.go +++ b/internal/inbox/models/models.go @@ -27,6 +27,7 @@ type Inbox struct { Channel string `db:"channel" json:"channel"` Enabled bool `db:"enabled" json:"enabled"` CSATEnabled bool `db:"csat_enabled" json:"csat_enabled"` + PromptTagsOnReply bool `db:"prompt_tags_on_reply" json:"prompt_tags_on_reply"` From string `db:"from" json:"from"` Config json.RawMessage `db:"config" json:"config"` Secret null.String `db:"secret" json:"secret"` diff --git a/internal/inbox/queries.sql b/internal/inbox/queries.sql index 20ccb93a..83e66348 100644 --- a/internal/inbox/queries.sql +++ b/internal/inbox/queries.sql @@ -1,24 +1,24 @@ -- name: get-active-inboxes -SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from", linked_email_inbox_id FROM inboxes where enabled is TRUE and deleted_at is NULL; +SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", linked_email_inbox_id FROM inboxes where enabled is TRUE and deleted_at is NULL; -- name: get-all-inboxes -SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from", linked_email_inbox_id FROM inboxes where deleted_at is NULL; +SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", linked_email_inbox_id FROM inboxes where deleted_at is NULL; -- name: insert-inbox INSERT INTO inboxes -(channel, config, "name", "from", csat_enabled, secret, linked_email_inbox_id) -VALUES($1, $2, $3, $4, $5, $6, $7) +(channel, config, "name", "from", csat_enabled, prompt_tags_on_reply, secret, linked_email_inbox_id) +VALUES($1, $2, $3, $4, $5, $6, $7, $8) RETURNING * -- name: get-inbox -SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from", secret, linked_email_inbox_id FROM inboxes where id = $1 and deleted_at is NULL; +SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", secret, linked_email_inbox_id FROM inboxes where id = $1 and deleted_at is NULL; -- name: get-inbox-by-uuid -SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, config, "from", secret, linked_email_inbox_id FROM inboxes where uuid = $1 and deleted_at is NULL; +SELECT id, uuid, created_at, updated_at, "name", deleted_at, channel, enabled, csat_enabled, prompt_tags_on_reply, config, "from", secret, linked_email_inbox_id FROM inboxes where uuid = $1 and deleted_at is NULL; -- name: update UPDATE inboxes -set channel = $2, config = $3, "name" = $4, "from" = $5, csat_enabled = $6, enabled = $7, secret = $8, linked_email_inbox_id = $9, updated_at = now() +set channel = $2, config = $3, "name" = $4, "from" = $5, csat_enabled = $6, prompt_tags_on_reply = $7, enabled = $8, secret = $9, linked_email_inbox_id = $10, updated_at = now() where id = $1 and deleted_at is NULL RETURNING *; diff --git a/internal/migrations/v2.1.0.go b/internal/migrations/v2.1.0.go new file mode 100644 index 00000000..91f806f3 --- /dev/null +++ b/internal/migrations/v2.1.0.go @@ -0,0 +1,19 @@ +package migrations + +import ( + "github.com/jmoiron/sqlx" + "github.com/knadh/koanf/v2" + "github.com/knadh/stuffbin" +) + +// V2_1_0 updates the database schema to v2.1.0. +func V2_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { + _, err := db.Exec(` + ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS prompt_tags_on_reply bool DEFAULT false NOT NULL; + `) + if err != nil { + return err + } + + return nil +} diff --git a/schema.sql b/schema.sql index 8e91fd0b..c50cb205 100644 --- a/schema.sql +++ b/schema.sql @@ -84,6 +84,7 @@ CREATE TABLE inboxes ( channel channels NOT NULL, enabled bool DEFAULT TRUE NOT NULL, csat_enabled bool DEFAULT false NOT NULL, + prompt_tags_on_reply bool DEFAULT false NOT NULL, config jsonb DEFAULT '{}'::jsonb NOT NULL, "from" TEXT NULL, secret TEXT NULL,