feat: add per-inbox signature management (#226)

* feat: add per-inbox signature management

- Introduced `InboxSignature` model to manage signatures specific to each inbox.
- Added API endpoints for fetching, creating, updating, and deleting inbox signatures.
- Updated UI components to support inbox-specific signatures, including overrides for signature position and separator.
- Implemented a new composable `useInboxSignatures` for managing inbox signatures in the frontend.
- Enhanced existing components to utilize inbox signatures, including the reply box and message signature settings.
- Added tests for the new inbox signatures functionality, ensuring proper behavior of the API and model validations.
- Updated translations for new UI elements related to inbox signatures.

* feat: implement inbox access validation and add related tests

* feat: enhance inbox signatures fetching and management logic
This commit is contained in:
Gabriel Jablonski 2026-02-26 19:53:03 -03:00 committed by GitHub
parent 21007bd20b
commit 56c5609ca0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1011 additions and 37 deletions

View File

@ -0,0 +1,63 @@
class Api::V1::Profile::InboxSignaturesController < Api::BaseController
before_action :set_user
before_action :set_inbox_signature, only: %i[show update destroy]
before_action :validate_inbox_access, only: %i[show update destroy]
def index
if params[:account_id].present?
validate_account_access!
return if performed?
@inbox_signatures = @user.inbox_signatures.joins(:inbox).where(inboxes: { account_id: params[:account_id] })
else
@inbox_signatures = @user.inbox_signatures
end
end
def show
head :not_found and return unless @inbox_signature
end
def update
if @inbox_signature
@inbox_signature.update!(inbox_signature_params)
else
@inbox_signature = @user.inbox_signatures.create!(
inbox_signature_params.merge(inbox_id: params[:inbox_id])
)
end
end
def destroy
@inbox_signature&.destroy!
head :no_content
end
private
def set_user
@user = current_user
end
def set_inbox_signature
@inbox_signature = @user.inbox_signatures.find_by(inbox_id: params[:inbox_id])
end
def inbox_signature_params
params.require(:inbox_signature).permit(:message_signature, :signature_position, :signature_separator)
end
def validate_inbox_access
inbox_id = params[:inbox_id]
return if InboxMember.exists?(user_id: @user.id, inbox_id: inbox_id)
head :unauthorized
end
def validate_account_access!
account_id = params[:account_id]
return if @user.account_ids.include?(account_id.to_i)
head :unauthorized
end
end

View File

@ -0,0 +1,25 @@
/* global axios */
const API_BASE = '/api/v1/profile/inbox_signatures';
export default {
getAll(accountId) {
return axios.get(API_BASE, {
params: { account_id: accountId },
});
},
get(inboxId) {
return axios.get(`${API_BASE}/${inboxId}`);
},
upsert(inboxId, params) {
return axios.put(`${API_BASE}/${inboxId}`, {
inbox_signature: params,
});
},
delete(inboxId) {
return axios.delete(`${API_BASE}/${inboxId}`);
},
};

View File

@ -4,6 +4,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
@ -84,6 +85,24 @@ const uiFlags = useMapGetter('contactConversations/getUIFlags');
const messageSignature = useMapGetter('getMessageSignature');
const inboxesList = useMapGetter('inboxes/getInboxes');
const {
fetchInboxSignatures,
getSignatureForInbox,
getSignatureSettingsForInbox,
} = useInboxSignatures();
fetchInboxSignatures();
const resolvedMessageSignature = computed(() => {
if (!targetInbox.value?.id) return messageSignature.value;
return getSignatureForInbox(targetInbox.value.id);
});
const resolvedSignatureSettings = computed(() => {
if (!targetInbox.value?.id) return null;
return getSignatureSettingsForInbox(targetInbox.value.id);
});
const sendWithSignature = computed(() =>
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
);
@ -307,8 +326,9 @@ useKeyboardEvents(keyboardEvents);
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:message-signature="messageSignature"
:message-signature="resolvedMessageSignature"
:send-with-signature="sendWithSignature"
:signature-settings="resolvedSignatureSettings"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"

View File

@ -35,6 +35,7 @@ const props = defineProps({
contactsUiFlags: { type: Object, default: null },
messageSignature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false },
signatureSettings: { type: Object, default: null },
formState: { type: Object, required: true },
});
@ -131,6 +132,7 @@ const newMessagePayload = () => {
directUploadsEnabled: props.isDirectUploadsEnabled,
sendWithSignature: props.sendWithSignature,
messageSignature: props.messageSignature,
signatureSettings: props.signatureSettings,
});
};

View File

@ -132,20 +132,15 @@ export const prepareNewMessagePayload = ({
directUploadsEnabled = false,
sendWithSignature = false,
messageSignature = '',
signatureSettings = null,
}) => {
let finalMessage = message;
if (sendWithSignature && messageSignature) {
const { signature_position, signature_separator } =
currentUser?.ui_settings || {};
const signatureSettings = {
position: signature_position || 'top',
separator: signature_separator || 'blank',
const settings = signatureSettings || {
position: currentUser?.ui_settings?.signature_position || 'top',
separator: currentUser?.ui_settings?.signature_separator || 'blank',
};
finalMessage = appendSignature(
message,
messageSignature,
signatureSettings
);
finalMessage = appendSignature(message, messageSignature, settings);
}
const payload = {

View File

@ -87,6 +87,9 @@ const props = defineProps({
// allowSignature is a kill switch, ensuring no signature methods
// are triggered except when this flag is true
allowSignature: { type: Boolean, default: false },
// Per-inbox overrides; when empty, falls back to currentUser.ui_settings
signaturePositionOverride: { type: String, default: '' },
signatureSeparatorOverride: { type: String, default: '' },
channelType: { type: String, default: '' },
conversationId: { type: Number, default: null },
medium: { type: String, default: '' },
@ -322,11 +325,19 @@ const sendWithSignature = computed(() => {
});
const signaturePosition = computed(() => {
return currentUser.value?.ui_settings?.signature_position || 'top';
return (
props.signaturePositionOverride ||
currentUser.value?.ui_settings?.signature_position ||
'top'
);
});
const signatureSeparator = computed(() => {
return currentUser.value?.ui_settings?.signature_separator || 'blank';
return (
props.signatureSeparatorOverride ||
currentUser.value?.ui_settings?.signature_separator ||
'blank'
);
});
const shouldShowSignaturePreview = computed(() => {
@ -850,6 +861,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
<!-- Signature preview at top -->
<div
v-if="shouldShowSignaturePreview && signaturePosition === 'top'"
v-tooltip="t('CONVERSATION.FOOTER.SIGNATURE_LABEL_TOP_TOOLTIP')"
class="signature-preview signature-preview--top"
>
<div class="signature-label">
@ -864,6 +876,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
<!-- Signature preview at bottom -->
<div
v-if="shouldShowSignaturePreview && signaturePosition === 'bottom'"
v-tooltip="t('CONVERSATION.FOOTER.SIGNATURE_LABEL_BOTTOM_TOOLTIP')"
class="signature-preview signature-preview--bottom"
>
<div class="signature-label">
@ -899,7 +912,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.signature-preview {
@apply px-1 py-1 text-n-slate-10 text-sm pointer-events-none select-none opacity-70;
@apply px-1 py-1 text-n-slate-10 text-sm select-none opacity-70 cursor-default;
&--top {
@apply border-b border-n-weak pb-1;

View File

@ -3,6 +3,7 @@ import { defineAsyncComponent, useTemplateRef } from 'vue';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures';
import { useTrack } from 'dashboard/composables';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
@ -97,6 +98,14 @@ export default {
fetchQuotedReplyFlagFromUISettings,
} = useUISettings();
const {
fetchInboxSignatures,
getSignatureForInbox,
getSignatureSettingsForInbox,
} = useInboxSignatures();
fetchInboxSignatures();
const { formatMessage } = useMessageFormatter();
const replyEditor = useTemplateRef('replyEditor');
@ -109,6 +118,8 @@ export default {
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
getSignatureForInbox,
getSignatureSettingsForInbox,
replyEditor,
copilot,
shortcutKey,
@ -147,7 +158,6 @@ export default {
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
messageSignature: 'getMessageSignature',
currentUser: 'getCurrentUser',
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
@ -344,6 +354,9 @@ export default {
isSignatureEnabledForInbox() {
return !this.isPrivate && this.sendWithSignature;
},
messageSignature() {
return this.getSignatureForInbox(this.inboxId);
},
isSignatureAvailable() {
return !!this.messageSignature;
},
@ -449,10 +462,10 @@ export default {
);
},
signaturePosition() {
return this.currentUser?.ui_settings?.signature_position || 'top';
return this.getSignatureSettingsForInbox(this.inboxId).position;
},
signatureSeparator() {
return this.currentUser?.ui_settings?.signature_separator || 'blank';
return this.getSignatureSettingsForInbox(this.inboxId).separator;
},
formattedSignature() {
if (!this.messageSignature) return '';
@ -703,11 +716,9 @@ export default {
if (!this.sendWithSignature || !this.messageSignature) {
return message;
}
const { signature_position, signature_separator } =
this.currentUser?.ui_settings || {};
const signatureSettings = {
position: signature_position || 'top',
separator: signature_separator || 'blank',
position: this.signaturePosition,
separator: this.signatureSeparator,
};
return appendSignature(message, this.messageSignature, signatureSettings);
},
@ -1354,6 +1365,8 @@ export default {
:variables="messageVariables"
:signature="messageSignature"
allow-signature
:signature-position-override="signaturePosition"
:signature-separator-override="signatureSeparator"
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"

View File

@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useInboxSignatures } from '../useInboxSignatures';
const mockInboxSignaturesAPI = vi.hoisted(() => ({
getAll: vi.fn(),
get: vi.fn(),
upsert: vi.fn(),
delete: vi.fn(),
}));
vi.mock('dashboard/api/inboxSignatures', () => ({
default: mockInboxSignaturesAPI,
}));
vi.mock('dashboard/composables/store', () => ({
useStoreGetters: () => ({
getCurrentUser: {
value: {
message_signature: '<p>Global Signature</p>',
ui_settings: {
signature_position: 'bottom',
signature_separator: '--',
},
},
},
getCurrentAccountId: {
value: 1,
},
}),
}));
describe('useInboxSignatures', () => {
beforeEach(() => {
vi.clearAllMocks();
const { _resetForTesting } = useInboxSignatures();
_resetForTesting();
});
describe('fetchInboxSignatures', () => {
it('fetches and caches inbox signatures', async () => {
const mockData = [
{
inbox_id: 1,
message_signature: '<p>Inbox 1 Sig</p>',
signature_position: 'top',
signature_separator: 'blank',
},
{
inbox_id: 2,
message_signature: '<p>Inbox 2 Sig</p>',
signature_position: 'bottom',
signature_separator: '--',
},
];
mockInboxSignaturesAPI.getAll.mockResolvedValue({ data: mockData });
const { fetchInboxSignatures, inboxSignatures, hasFetched } =
useInboxSignatures();
await fetchInboxSignatures();
expect(mockInboxSignaturesAPI.getAll).toHaveBeenCalled();
expect(inboxSignatures.value[1].message_signature).toBe(
'<p>Inbox 1 Sig</p>'
);
expect(inboxSignatures.value[2].message_signature).toBe(
'<p>Inbox 2 Sig</p>'
);
expect(hasFetched.value).toBe(true);
});
});
describe('getSignatureForInbox', () => {
it('returns inbox-specific signature when available', async () => {
mockInboxSignaturesAPI.getAll.mockResolvedValue({
data: [
{
inbox_id: 1,
message_signature: '<p>Inbox 1 Sig</p>',
signature_position: 'top',
signature_separator: 'blank',
},
],
});
const { fetchInboxSignatures, getSignatureForInbox } =
useInboxSignatures();
await fetchInboxSignatures();
expect(getSignatureForInbox(1)).toBe('<p>Inbox 1 Sig</p>');
});
it('falls back to global signature when no inbox-specific override', async () => {
mockInboxSignaturesAPI.getAll.mockResolvedValue({ data: [] });
const { fetchInboxSignatures, getSignatureForInbox } =
useInboxSignatures();
await fetchInboxSignatures();
expect(getSignatureForInbox(999)).toBe('<p>Global Signature</p>');
});
});
describe('getSignatureSettingsForInbox', () => {
it('returns inbox-specific settings when available', async () => {
mockInboxSignaturesAPI.getAll.mockResolvedValue({
data: [
{
inbox_id: 1,
message_signature: '<p>Sig</p>',
signature_position: 'top',
signature_separator: 'blank',
},
],
});
const { fetchInboxSignatures, getSignatureSettingsForInbox } =
useInboxSignatures();
await fetchInboxSignatures();
const settings = getSignatureSettingsForInbox(1);
expect(settings.position).toBe('top');
expect(settings.separator).toBe('blank');
});
it('falls back to global user settings when no inbox-specific override', async () => {
mockInboxSignaturesAPI.getAll.mockResolvedValue({ data: [] });
const { fetchInboxSignatures, getSignatureSettingsForInbox } =
useInboxSignatures();
await fetchInboxSignatures();
const settings = getSignatureSettingsForInbox(999);
expect(settings.position).toBe('bottom');
expect(settings.separator).toBe('--');
});
});
describe('upsertInboxSignature', () => {
it('updates the cache after upserting', async () => {
mockInboxSignaturesAPI.getAll.mockResolvedValue({ data: [] });
mockInboxSignaturesAPI.upsert.mockResolvedValue({
data: {
inbox_id: 3,
message_signature: '<p>New Sig</p>',
signature_position: 'top',
signature_separator: 'blank',
},
});
const {
fetchInboxSignatures,
upsertInboxSignature,
getSignatureForInbox,
} = useInboxSignatures();
await fetchInboxSignatures();
await upsertInboxSignature(3, {
message_signature: '<p>New Sig</p>',
signature_position: 'top',
signature_separator: 'blank',
});
expect(getSignatureForInbox(3)).toBe('<p>New Sig</p>');
});
});
describe('deleteInboxSignature', () => {
it('removes from cache after deleting', async () => {
mockInboxSignaturesAPI.getAll.mockResolvedValue({
data: [
{
inbox_id: 1,
message_signature: '<p>Sig</p>',
signature_position: 'top',
signature_separator: 'blank',
},
],
});
mockInboxSignaturesAPI.delete.mockResolvedValue({});
const {
fetchInboxSignatures,
deleteInboxSignature,
hasInboxSignature,
getSignatureForInbox,
} = useInboxSignatures();
await fetchInboxSignatures();
expect(hasInboxSignature(1)).toBe(true);
await deleteInboxSignature(1);
expect(hasInboxSignature(1)).toBe(false);
// Falls back to global
expect(getSignatureForInbox(1)).toBe('<p>Global Signature</p>');
});
});
});

View File

@ -0,0 +1,116 @@
import { ref, computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import inboxSignaturesAPI from 'dashboard/api/inboxSignatures';
const inboxSignatures = ref({});
const isFetching = ref(false);
const hasFetched = ref(false);
/**
* Composable for managing per-inbox signatures.
* Provides methods to fetch, upsert, and delete inbox-specific signatures,
* with fallback to the global user signature.
*/
export function useInboxSignatures() {
const getters = useStoreGetters();
const currentUser = computed(() => getters.getCurrentUser.value);
const currentAccountId = computed(() => getters.getCurrentAccountId.value);
const globalSignature = computed(
() => currentUser.value?.message_signature || ''
);
const fetchInboxSignatures = async ({ force = false } = {}) => {
if (isFetching.value) return;
if (hasFetched.value && !force) return;
isFetching.value = true;
try {
const { data } = await inboxSignaturesAPI.getAll(currentAccountId.value);
const signaturesMap = {};
data.forEach(sig => {
signaturesMap[sig.inbox_id] = sig;
});
inboxSignatures.value = signaturesMap;
hasFetched.value = true;
} catch {
// Silently fail — fallback to global signature
} finally {
isFetching.value = false;
}
};
const upsertInboxSignature = async (inboxId, params) => {
const { data } = await inboxSignaturesAPI.upsert(inboxId, params);
inboxSignatures.value = {
...inboxSignatures.value,
[inboxId]: data,
};
return data;
};
const deleteInboxSignature = async inboxId => {
await inboxSignaturesAPI.delete(inboxId);
const updated = { ...inboxSignatures.value };
delete updated[inboxId];
inboxSignatures.value = updated;
};
/**
* Returns the inbox-specific signature if it exists, otherwise the global signature.
*/
const getSignatureForInbox = inboxId => {
const inboxSig = inboxSignatures.value[inboxId];
return inboxSig?.message_signature || globalSignature.value;
};
/**
* Returns signature settings (position, separator) for the given inbox,
* falling back to the user's global settings.
*/
const getSignatureSettingsForInbox = inboxId => {
const inboxSig = inboxSignatures.value[inboxId];
if (inboxSig) {
return {
position: inboxSig.signature_position || 'top',
separator: inboxSig.signature_separator || 'blank',
};
}
const uiSettings = currentUser.value?.ui_settings || {};
return {
position: uiSettings.signature_position || 'top',
separator: uiSettings.signature_separator || 'blank',
};
};
/**
* Returns the raw inbox signature record if one exists.
*/
const getInboxSignature = inboxId => {
return inboxSignatures.value[inboxId] || null;
};
/**
* Checks if a specific inbox has a custom signature override.
*/
const hasInboxSignature = inboxId => {
return !!inboxSignatures.value[inboxId];
};
return {
inboxSignatures: computed(() => inboxSignatures.value),
isFetching: computed(() => isFetching.value),
hasFetched: computed(() => hasFetched.value),
fetchInboxSignatures,
upsertInboxSignature,
deleteInboxSignature,
getSignatureForInbox,
getSignatureSettingsForInbox,
getInboxSignature,
hasInboxSignature,
_resetForTesting: () => {
inboxSignatures.value = {};
isFetching.value = false;
hasFetched.value = false;
},
};
}

View File

@ -190,7 +190,9 @@
"ENABLE_SIGN_TOOLTIP": "Enable signature",
"DISABLE_SIGN_TOOLTIP": "Disable signature",
"SIGNATURE_LABEL_TOP": "↓ Signature",
"SIGNATURE_LABEL_TOP_TOOLTIP": "The signature will be sent at the top of the message",
"SIGNATURE_LABEL_BOTTOM": "↑ Signature",
"SIGNATURE_LABEL_BOTTOM_TOOLTIP": "The signature will be sent at the bottom of the message",
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",

View File

@ -66,6 +66,13 @@
"BTN_TEXT": "Save message signature",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully",
"RESET_TO_DEFAULT": "Reset to default",
"RESET_SUCCESS": "Inbox signature removed, using default signature",
"INBOX_SELECTOR": {
"LABEL": "Inbox",
"DEFAULT": "Default (all inboxes)",
"CUSTOM": "Custom"
},
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB",

View File

@ -185,7 +185,9 @@
"ENABLE_SIGN_TOOLTIP": "Ativar assinatura",
"DISABLE_SIGN_TOOLTIP": "Desativar assinatura",
"SIGNATURE_LABEL_TOP": "↓ Assinatura",
"SIGNATURE_LABEL_TOP_TOOLTIP": "A assinatura será enviada no início da mensagem",
"SIGNATURE_LABEL_BOTTOM": "↑ Assinatura",
"SIGNATURE_LABEL_BOTTOM_TOOLTIP": "A assinatura será enviada no final da mensagem",
"MSG_INPUT": "Shift + enter para nova linha. Digite '/' para selecionar uma Resposta Pronta.",
"PRIVATE_MSG_INPUT": "A mensagem será visível apenas para agentes",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "A assinatura da mensagem não está configurada. Por favor, configure-a nas configurações do perfil.",

View File

@ -66,6 +66,13 @@
"BTN_TEXT": "Salvar assinatura da mensagem",
"API_ERROR": "Não foi possível salvar a assinatura! Tente novamente",
"API_SUCCESS": "Assinatura salva com sucesso",
"RESET_TO_DEFAULT": "Restaurar para padrão",
"RESET_SUCCESS": "Assinatura da caixa de entrada removida, usando assinatura padrão",
"INBOX_SELECTOR": {
"LABEL": "Caixa de entrada",
"DEFAULT": "Padrão (todas as caixas de entrada)",
"CUSTOM": "Personalizada"
},
"IMAGE_UPLOAD_ERROR": "Não foi possível fazer o upload da imagem! Tente novamente",
"IMAGE_UPLOAD_SUCCESS": "Imagem adicionada com sucesso. Por favor clique em salvar para salvar a assinatura",
"IMAGE_UPLOAD_SIZE_ERROR": "O tamanho da imagem deve ser menor que {size}MB",

View File

@ -2,6 +2,7 @@
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures';
import { useFontSize } from 'dashboard/composables/useFontSize';
import { useBranding } from 'shared/composables/useBranding';
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
@ -46,6 +47,10 @@ export default {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
const { currentFontSize, updateFontSize } = useFontSize();
const { replaceInstallationName } = useBranding();
const { upsertInboxSignature, deleteInboxSignature, fetchInboxSignatures } =
useInboxSignatures();
fetchInboxSignatures();
return {
currentFontSize,
@ -53,6 +58,8 @@ export default {
isEditorHotKeyEnabled,
updateUISettings,
replaceInstallationName,
upsertInboxSignature,
deleteInboxSignature,
};
},
data() {
@ -185,6 +192,37 @@ export default {
);
}
},
async updateInboxSignature(inboxId, params, done) {
try {
await this.upsertInboxSignature(inboxId, params);
useAlert(
this.$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS')
);
} catch (error) {
useAlert(
parseAPIErrorResponse(error) ||
this.$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_ERROR')
);
} finally {
if (done) done();
}
},
async handleDeleteInboxSignature(inboxId, done) {
try {
await this.deleteInboxSignature(inboxId);
useAlert(
this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.RESET_SUCCESS'
)
);
} catch (error) {
useAlert(
this.$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_ERROR')
);
} finally {
if (done) done();
}
},
updateProfilePicture({ file, url }) {
this.avatarFile = file;
this.avatarUrl = url;
@ -274,6 +312,8 @@ export default {
:signature-position="signaturePosition"
:signature-separator="signatureSeparator"
@update-signature="updateSignature"
@update-inbox-signature="updateInboxSignature"
@delete-inbox-signature="handleDeleteInboxSignature"
/>
</FormSection>
<FormSection

View File

@ -2,8 +2,11 @@
import { ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useMapGetter } from 'dashboard/composables/store';
import { useInboxSignatures } from 'dashboard/composables/useInboxSignatures';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
const props = defineProps({
messageSignature: {
@ -22,14 +25,60 @@ const props = defineProps({
},
});
const emit = defineEmits(['updateSignature']);
const emit = defineEmits([
'updateSignature',
'updateInboxSignature',
'deleteInboxSignature',
]);
const INBOX_OPTION_DEFAULT = 'default';
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const inboxes = useMapGetter('inboxes/getInboxes');
const { fetchInboxSignatures, getInboxSignature, hasInboxSignature } =
useInboxSignatures();
const selectedInboxId = ref(INBOX_OPTION_DEFAULT);
const selectedInbox = ref(null);
const signature = ref(props.messageSignature);
const signaturePosition = ref(props.signaturePosition);
const signatureSeparator = ref(props.signatureSeparator);
const isSaving = ref(false);
const defaultOption = computed(() => ({
id: INBOX_OPTION_DEFAULT,
name: t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.INBOX_SELECTOR.DEFAULT'
),
}));
const inboxOptions = computed(() => {
const customLabel = t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.INBOX_SELECTOR.CUSTOM'
);
const items = inboxes.value.map(inbox => ({
...inbox,
icon: hasInboxSignature(inbox.id) ? 'i-lucide-pen-line' : undefined,
name: hasInboxSignature(inbox.id)
? `${inbox.name} (${customLabel})`
: inbox.name,
}));
return [defaultOption.value, ...items];
});
const isDefaultSelected = computed(
() => selectedInboxId.value === INBOX_OPTION_DEFAULT
);
// Initialize the selected inbox object
selectedInbox.value = defaultOption.value;
const currentInboxHasOverride = computed(() => {
if (isDefaultSelected.value) return false;
return hasInboxSignature(selectedInboxId.value);
});
const positionOptions = computed(() => [
{
@ -83,10 +132,40 @@ const messagePreview = computed(() => {
return `${sampleMessage.value}${separator}${formattedSignature.value}`;
});
const loadSignatureForSelection = () => {
if (isDefaultSelected.value) {
signature.value = props.messageSignature;
signaturePosition.value = props.signaturePosition;
signatureSeparator.value = props.signatureSeparator;
return;
}
const inboxSig = getInboxSignature(selectedInboxId.value);
if (inboxSig) {
signature.value = inboxSig.message_signature;
signaturePosition.value = inboxSig.signature_position || 'top';
signatureSeparator.value = inboxSig.signature_separator || 'blank';
} else {
// Pre-fill with global signature for convenience
signature.value = props.messageSignature;
signaturePosition.value = props.signaturePosition;
signatureSeparator.value = props.signatureSeparator;
}
};
// Keep selectedInboxId in sync with the SingleSelect object model
watch(selectedInbox, newVal => {
selectedInboxId.value = newVal?.id ?? INBOX_OPTION_DEFAULT;
loadSignatureForSelection();
});
// Fetch inbox signatures on mount, then reload form values
fetchInboxSignatures().then(() => loadSignatureForSelection());
watch(
() => props.signaturePosition,
newValue => {
signaturePosition.value = newValue;
if (isDefaultSelected.value) signaturePosition.value = newValue;
},
{ immediate: true }
);
@ -94,7 +173,7 @@ watch(
watch(
() => props.signatureSeparator,
newValue => {
signatureSeparator.value = newValue;
if (isDefaultSelected.value) signatureSeparator.value = newValue;
},
{ immediate: true }
);
@ -102,33 +181,77 @@ watch(
watch(
() => props.messageSignature ?? '',
newValue => {
signature.value = newValue;
if (isDefaultSelected.value) signature.value = newValue;
},
{ immediate: true }
);
const updateSignature = () => {
emit(
'updateSignature',
signature.value,
signaturePosition.value,
signatureSeparator.value
);
if (isDefaultSelected.value) {
emit(
'updateSignature',
signature.value,
signaturePosition.value,
signatureSeparator.value
);
} else {
isSaving.value = true;
emit(
'updateInboxSignature',
selectedInboxId.value,
{
message_signature: signature.value,
signature_position: signaturePosition.value,
signature_separator: signatureSeparator.value,
},
() => {
isSaving.value = false;
}
);
}
};
const handlePositionChange = value => {
signaturePosition.value = value;
emit('updateSignature', signature.value, value, signatureSeparator.value);
};
const handleSeparatorChange = value => {
signatureSeparator.value = value;
emit('updateSignature', signature.value, signaturePosition.value, value);
};
const resetToDefault = () => {
isSaving.value = true;
emit('deleteInboxSignature', selectedInboxId.value, () => {
loadSignatureForSelection();
isSaving.value = false;
});
};
</script>
<template>
<form class="flex flex-col gap-6" @submit.prevent="updateSignature()">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.INBOX_SELECTOR.LABEL'
)
}}
</label>
<div class="flex items-center gap-2">
<SingleSelect
v-model="selectedInbox"
:options="inboxOptions"
:placeholder="
t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.INBOX_SELECTOR.DEFAULT'
)
"
disable-deselect
class="min-w-0 [&_button]:max-w-xs [&_button]:min-w-0"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex flex-col gap-2">
<label
@ -190,7 +313,7 @@ const handleSeparatorChange = value => {
<WootMessageEditor
id="message-signature-input"
v-model="signature"
class="message-editor h-[10rem] !px-3"
class="message-editor h-40 !px-3"
is-format-mode
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
channel-type="Context::MessageSignature"
@ -226,11 +349,22 @@ const handleSeparatorChange = value => {
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.PREVIEW.NOTE') }}
</p>
</div>
<div>
<div class="flex items-center gap-3">
<NextButton
type="submit"
:is-loading="isSaving"
:label="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT')"
/>
<NextButton
v-if="!isDefaultSelected && currentInboxHasOverride"
type="button"
variant="faded"
color="ruby"
:label="
$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.RESET_TO_DEFAULT')
"
@click="resetToDefault"
/>
</div>
</form>
</template>

View File

@ -64,6 +64,7 @@ class Inbox < ApplicationRecord
has_many :contacts, through: :contact_inboxes
has_many :inbox_members, dependent: :destroy_async
has_many :inbox_signatures, dependent: :destroy_async
has_many :members, through: :inbox_members, source: :user
has_many :conversations, dependent: :destroy_async
has_many :messages, dependent: :destroy_async

View File

@ -0,0 +1,28 @@
# == Schema Information
#
# Table name: inbox_signatures
#
# id :bigint not null, primary key
# message_signature :text not null
# signature_position :string default("top"), not null
# signature_separator :string default("blank"), not null
# created_at :datetime not null
# updated_at :datetime not null
# inbox_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_inbox_signatures_on_inbox_id (inbox_id)
# index_inbox_signatures_on_user_id_and_inbox_id (user_id,inbox_id) UNIQUE
#
class InboxSignature < ApplicationRecord
belongs_to :user
belongs_to :inbox
validates :message_signature, presence: true
validates :user_id, uniqueness: { scope: :inbox_id }
validates :signature_position, inclusion: { in: %w[top bottom] }
validates :signature_separator, inclusion: { in: %w[blank --] }
end

View File

@ -96,6 +96,7 @@ class User < ApplicationRecord
has_many :participating_conversations, through: :conversation_participants, source: :conversation
has_many :inbox_members, dependent: :destroy_async
has_many :inbox_signatures, dependent: :destroy_async
has_many :inboxes, through: :inbox_members, source: :inbox
has_many :messages, as: :sender, dependent: :nullify
has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify

View File

@ -0,0 +1,5 @@
json.id inbox_signature.id
json.inbox_id inbox_signature.inbox_id
json.message_signature inbox_signature.message_signature
json.signature_position inbox_signature.signature_position
json.signature_separator inbox_signature.signature_separator

View File

@ -0,0 +1,3 @@
json.array! @inbox_signatures do |inbox_signature|
json.partial! 'inbox_signature', inbox_signature: inbox_signature
end

View File

@ -0,0 +1 @@
json.partial! 'inbox_signature', inbox_signature: @inbox_signature

View File

@ -0,0 +1 @@
json.partial! 'inbox_signature', inbox_signature: @inbox_signature

View File

@ -390,6 +390,7 @@ Rails.application.routes.draw do
post :verify
post :backup_codes
end
resources :inbox_signatures, only: %i[index show update destroy], param: :inbox_id
end
end

View File

@ -0,0 +1,14 @@
class CreateInboxSignatures < ActiveRecord::Migration[7.1]
def change
create_table :inbox_signatures do |t|
t.references :user, null: false, index: false
t.references :inbox, null: false, index: false
t.text :message_signature, null: false
t.string :signature_position, default: 'top', null: false
t.string :signature_separator, default: 'blank', null: false
t.timestamps
end
add_index :inbox_signatures, %i[user_id inbox_id], unique: true
end
end

View File

@ -0,0 +1,5 @@
class AddInboxIdIndexToInboxSignatures < ActiveRecord::Migration[7.1]
def change
add_index :inbox_signatures, :inbox_id
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_02_01_162122) do
ActiveRecord::Schema[7.1].define(version: 2026_02_26_194714) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@ -857,6 +857,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_01_162122) do
t.index ["inbox_id"], name: "index_inbox_members_on_inbox_id"
end
create_table "inbox_signatures", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "inbox_id", null: false
t.text "message_signature", null: false
t.string "signature_position", default: "top", null: false
t.string "signature_separator", default: "blank", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["inbox_id"], name: "index_inbox_signatures_on_inbox_id"
t.index ["user_id", "inbox_id"], name: "index_inbox_signatures_on_user_id_and_inbox_id", unique: true
end
create_table "inboxes", id: :serial, force: :cascade do |t|
t.integer "channel_id", null: false
t.integer "account_id", null: false

View File

@ -22,10 +22,15 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService
{
'channel_type' => conversation.inbox.channel_type,
'agent_name' => user.name,
'agent_signature' => user.message_signature.presence
'agent_signature' => resolved_signature
}
end
def resolved_signature
inbox_signature = user.inbox_signatures.find_by(inbox_id: conversation.inbox_id)
inbox_signature&.message_signature || user.message_signature.presence
end
def render_liquid_template(template_content, variables = {})
Liquid::Template.parse(template_content).render(variables)
end

View File

@ -0,0 +1,225 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Profile Inbox Signatures API', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
before do
create(:inbox_member, user: agent, inbox: inbox)
end
describe 'GET /api/v1/profile/inbox_signatures' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get '/api/v1/profile/inbox_signatures'
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'returns all inbox signatures for the current account' do
inbox_signature = create(:inbox_signature, user: agent, inbox: inbox)
get '/api/v1/profile/inbox_signatures',
params: { account_id: account.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response.length).to eq(1)
expect(json_response[0]['inbox_id']).to eq(inbox.id)
expect(json_response[0]['message_signature']).to eq(inbox_signature.message_signature)
end
it 'does not return signatures for inboxes from other accounts' do
other_account = create(:account)
other_inbox = create(:inbox, account: other_account)
create(:inbox_signature, user: agent, inbox: other_inbox)
get '/api/v1/profile/inbox_signatures',
params: { account_id: account.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response.length).to eq(0)
end
it 'returns unauthorized when filtering by an account the user does not belong to' do
other_account = create(:account)
get '/api/v1/profile/inbox_signatures',
params: { account_id: other_account.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'GET /api/v1/profile/inbox_signatures/:inbox_id' do
context 'when the inbox signature exists' do
it 'returns the inbox signature' do
inbox_signature = create(:inbox_signature, user: agent, inbox: inbox)
get "/api/v1/profile/inbox_signatures/#{inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['inbox_id']).to eq(inbox.id)
expect(json_response['message_signature']).to eq(inbox_signature.message_signature)
expect(json_response['signature_position']).to eq('top')
expect(json_response['signature_separator']).to eq('blank')
end
end
context 'when the inbox signature does not exist' do
it 'returns not found' do
get "/api/v1/profile/inbox_signatures/#{inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when the user is not a member of the inbox' do
let(:non_member_inbox) { create(:inbox, account: account) }
it 'returns unauthorized' do
get "/api/v1/profile/inbox_signatures/#{non_member_inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'PUT /api/v1/profile/inbox_signatures/:inbox_id' do
let(:signature_params) do
{
inbox_signature: {
message_signature: '<p>Custom Signature</p>',
signature_position: 'bottom',
signature_separator: '--'
}
}
end
context 'when the inbox signature does not exist' do
it 'creates a new inbox signature' do
expect do
put "/api/v1/profile/inbox_signatures/#{inbox.id}",
params: signature_params,
headers: agent.create_new_auth_token,
as: :json
end.to change(InboxSignature, :count).by(1)
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['inbox_id']).to eq(inbox.id)
expect(json_response['message_signature']).to eq('<p>Custom Signature</p>')
expect(json_response['signature_position']).to eq('bottom')
expect(json_response['signature_separator']).to eq('--')
end
end
context 'when the inbox signature already exists' do
it 'updates the existing inbox signature' do
create(:inbox_signature, user: agent, inbox: inbox)
expect do
put "/api/v1/profile/inbox_signatures/#{inbox.id}",
params: signature_params,
headers: agent.create_new_auth_token,
as: :json
end.not_to change(InboxSignature, :count)
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['message_signature']).to eq('<p>Custom Signature</p>')
expect(json_response['signature_position']).to eq('bottom')
end
end
context 'when the user is not a member of the inbox' do
let(:non_member_inbox) { create(:inbox, account: account) }
it 'returns unauthorized' do
put "/api/v1/profile/inbox_signatures/#{non_member_inbox.id}",
params: signature_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when params are invalid' do
let(:invalid_params) do
{
inbox_signature: {
message_signature: '<p>Custom Signature</p>',
signature_position: 'invalid'
}
}
end
it 'returns unprocessable entity and does not create a signature' do
expect do
put "/api/v1/profile/inbox_signatures/#{inbox.id}",
params: invalid_params,
headers: agent.create_new_auth_token,
as: :json
end.not_to change(InboxSignature, :count)
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['attributes']).to include('signature_position')
end
end
end
describe 'DELETE /api/v1/profile/inbox_signatures/:inbox_id' do
it 'deletes the inbox signature' do
create(:inbox_signature, user: agent, inbox: inbox)
expect do
delete "/api/v1/profile/inbox_signatures/#{inbox.id}",
headers: agent.create_new_auth_token,
as: :json
end.to change(InboxSignature, :count).by(-1)
expect(response).to have_http_status(:no_content)
end
it 'returns no content even when signature does not exist' do
delete "/api/v1/profile/inbox_signatures/#{inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:no_content)
end
context 'when the user is not a member of the inbox' do
let(:non_member_inbox) { create(:inbox, account: account) }
it 'returns unauthorized' do
delete "/api/v1/profile/inbox_signatures/#{non_member_inbox.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
FactoryBot.define do
factory :inbox_signature do
user
inbox
message_signature { '<p>Best regards,<br>Test Agent</p>' }
signature_position { 'top' }
signature_separator { 'blank' }
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe InboxSignature do
describe 'associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:inbox) }
end
describe 'validations' do
subject { create(:inbox_signature, user: user, inbox: inbox) }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
it { is_expected.to validate_presence_of(:message_signature) }
it { is_expected.to validate_uniqueness_of(:user_id).scoped_to(:inbox_id) }
it { is_expected.to validate_inclusion_of(:signature_position).in_array(%w[top bottom]) }
it { is_expected.to validate_inclusion_of(:signature_separator).in_array(%w[blank --]) }
end
end