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,