1<template>
2 <SettingsLayoutBase
3 :back-label="__('Email Settings')"
4 :on-back="goBack"
5 :dirty="isDirty"
6 >
7 <template #header-actions>
8 <Transition name="fade">
9 <div v-if="isDirty">
10 <Button
11 variant="solid"
12 :label="__('Update')"
13 :loading="user.save.loading || saveEmailAccountsResource.loading"
14 @click="update"
15 /></div
16 ></Transition>
17 </template>
18 <template #content>
19 <div class="flex flex-col gap-4">
20 <div class="flex flex-col gap-1">
21 <span class="text-base-medium text-ink-gray-8">
22 {{ __("Signature") }}
23 </span>
24 <span class="text-p-sm text-ink-gray-6">
25 {{ __("Manage your email signature.") }}
26 </span>
27 </div>
28 <div>
29 <CompactEditor
30 v-model="user.doc.email_signature"
31 :upload-fn="
32 (file: any, options: any) =>
33 uploadFunction(file, 'User', userId, false, options)
34 "
35 :placeholder="__('Write your email signature here.')"
36 />
37 </div>
38 </div>
39 <div class="flex flex-col gap-4 mt-6">
40 <div class="flex flex-col gap-1">
41 <span class="text-base-medium text-ink-gray-8">
42 {{ __("Emails") }}
43 </span>
44 <span class="text-p-sm text-ink-gray-6">
45 {{
46 __(
47 "Switch between outgoing email accounts when sending emails from your configured accounts."
48 )
49 }}
50 </span>
51 </div>
52 <div>
53 <div
54 v-if="linkedEmails.length"
55 class="w-full border rounded-5 mb-2 border-outline-elevation-2"
56 >
57 <div
58 class="grid grid-cols-[4fr_4fr_0.3fr] gap-2 px-4 py-3 text-sm-medium text-ink-gray-5 border-b border-outline-elevation-2"
59 >
60 <span>{{ __("Email Account") }}</span>
61 <span>{{ __("Email") }}</span>
62 <span></span>
63 </div>
64 <div
65 v-for="e in linkedEmails"
66 :key="e.email_account"
67 class="grid grid-cols-[4fr_4fr_0.3fr] gap-2 group items-center px-4 py-2.5 text-base border-b border-outline-elevation-2 last:border-b-0"
68 >
69 <span class="text-ink-gray-8 font-medium truncate">
70 {{ e.email_account }}
71 </span>
72 <span class="text-ink-gray-6 truncate">{{ e.email_id }}</span>
73 <div
74 class="group-hover:opacity-100 opacity-0 transition-opacity [@media(hover:none)]:opacity-100"
75 >
76 <Button
77 class="w-10"
78 variant="ghost"
79 :tooltip="__('Remove')"
80 :aria-label="__('Remove')"
81 icon="lucide-x"
82 @click.prevent="removeEmail(e)"
83 />
84 </div>
85 </div>
86 </div>
87 <Combobox
88 :model-value="null"
89 :options="filteredEmails"
90 @update:selected-option="(option) => option && addEmail(option)"
91 >
92 <template #trigger>
93 <Button
94 class="!bg-surface-elevation-2"
95 variant="outline"
96 :label="__('Add Email')"
97 iconLeft="lucide-plus"
98 />
99 </template>
100 <template #item-label="{ item }">
101 <div class="flex flex-col gap-1 text-ink-gray-9">
102 <div>{{ item.label }}</div>
103 <div class="text-ink-gray-4 text-sm">
104 {{ item.email }}
105 </div>
106 </div>
107 </template>
108 </Combobox>
109 </div>
110 </div>
111 </template>
112 </SettingsLayoutBase>
113 <ConfirmDialog
114 v-model="showConfirmDialog.show"
115 :title="showConfirmDialog.title"
116 :message="showConfirmDialog.message"
117 :onConfirm="showConfirmDialog.onConfirm"
118 :onCancel="
119 () => {
120 if (showConfirmDialog.onCancel) {
121 showConfirmDialog.onCancel();
122 } else {
123 showConfirmDialog.show = false;
124 }
125 }
126 "
127 />
128</template>
129<script setup>
130import CompactEditor from "@/components/CompactEditor.vue";
131import ConfirmDialog from "@/components/ConfirmDialog.vue";
132import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
133import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
134import { useAuthStore } from "@/stores/auth";
135import { __ } from "@/translation";
136import { normalize, uploadFunction } from "@/utils";
137import {
138 Button,
139 Combobox,
140 createDocumentResource,
141 createResource,
142 toast,
143} from "frappe-ui";
144import { computed, ref, watch } from "vue";
145import { disableSettingModalOutsideClick } from "../settingsModal";
146
147const { userId } = useAuthStore();
148const user = createDocumentResource({ doctype: "User", name: userId });
149const emit = defineEmits(["updateStep"]);
150
151const currentUserEmailInfo = getUserEmailInfo();
152
153// user_emails is System Manager-only, so the User doc never carries it for
154// agents; the list comes from the email info API and is saved through it
155const linkedEmails = ref([]);
156watch(
157 () => currentUserEmailInfo.data?.outgoing_emails,
158 (emails, previousEmails) => {
159 // the info is shared with the composer; a reload from there must not
160 // wipe unsaved edits here
161 if (
162 sortedAccountNames(linkedEmails.value) !==
163 sortedAccountNames(previousEmails)
164 )
165 return;
166 linkedEmails.value = [...(emails || [])];
167 },
168 { immediate: true }
169);
170
171const saveEmailAccountsResource = createResource({
172 url: "opendesk.api.auth.set_user_email_accounts",
173 makeParams: () => ({
174 email_accounts: linkedAccountNames(linkedEmails.value),
175 }),
176});
177
178const filteredEmails = computed(() => {
179 if (!currentUserEmailInfo.data?.available_emails) return [];
180 const linkedAccounts = linkedAccountNames(linkedEmails.value);
181 return currentUserEmailInfo.data.available_emails
182 .map((doc) => ({
183 label: doc.name,
184 value: doc.name,
185 email: doc.email_id,
186 }))
187 .filter((e) => !linkedAccounts.includes(e.value));
188});
189
190const isSignatureDirty = computed(() => {
191 return (
192 normalize(currentUserEmailInfo.data?.email_signature) !==
193 normalize(user?.doc?.email_signature)
194 );
195});
196
197const isUserEmailListDirty = computed(() => {
198 return (
199 sortedAccountNames(currentUserEmailInfo.data?.outgoing_emails) !==
200 sortedAccountNames(linkedEmails.value)
201 );
202});
203
204const isDirty = computed(() => {
205 return isSignatureDirty.value || isUserEmailListDirty.value;
206});
207
208if (isDirty.value) {
209 disableSettingModalOutsideClick.value = true;
210} else {
211 disableSettingModalOutsideClick.value = false;
212}
213
214function linkedAccountNames(list = []) {
215 return list.map((e) => e.email_account);
216}
217
218function sortedAccountNames(list) {
219 return linkedAccountNames(list).sort().join();
220}
221
222function addEmail(email) {
223 linkedEmails.value.push({
224 email_account: email.value,
225 email_id: email.email,
226 });
227}
228
229function removeEmail(email) {
230 linkedEmails.value = linkedEmails.value.filter(
231 (e) => e.email_account !== email.email_account
232 );
233}
234
235async function update() {
236 // one after the other, since both save the same User doc
237 try {
238 if (isSignatureDirty.value) await user.save.submit();
239 if (isUserEmailListDirty.value) await saveEmailAccountsResource.submit();
240 } catch {
241 return; // the fallback error handler already showed a toast
242 }
243 toast.success(__("Email settings updated successfully."));
244 user.reload();
245 await currentUserEmailInfo.reload();
246 linkedEmails.value = [...currentUserEmailInfo.data.outgoing_emails];
247}
248
249watch(isDirty, (val) => {
250 disableSettingModalOutsideClick.value = val;
251});
252
253const showConfirmDialog = ref({
254 show: false,
255 title: "",
256 message: "",
257 onConfirm: () => {},
258});
259
260const goBack = () => {
261 const confirmDialogInfo = {
262 show: true,
263 title: __("Unsaved changes"),
264 message: __(
265 "Are you sure you want to go back? Unsaved changes will be lost."
266 ),
267 onConfirm: goBack,
268 };
269 if (isDirty.value && !showConfirmDialog.value.show) {
270 showConfirmDialog.value = confirmDialogInfo;
271 return;
272 }
273
274 showConfirmDialog.value.show = false;
275 emit("updateStep", "profile");
276};
277</script>
278