1<template>
2 <SettingsLayoutBase
3 :title="__('Edit Email')"
4 :description="__('Edit your email account')"
5 >
6 <template #content>
7 <div class="flex h-full flex-col gap-4">
8 <div class="overflow-y-auto flex flex-col gap-4 p-0.5">
9 <div class="w-fit">
10 <EmailProviderIcon
11 :logo="emailIcon[state.service || accountData.service]"
12 :service-name="state.service || accountData.service"
13 />
14 </div>
15 <div
16 class="flex items-center gap-2 rounded-5 p-2 ring-1 ring-outline-elevation-2"
17 >
18 <CircleAlert
19 class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-5"
20 />
21 <div class="text-wrap text-xs text-ink-gray-7 flex flex-col gap-1">
22 <span>
23 {{ info.description }}
24 <a
25 :href="info.link"
26 target="_blank"
27 class="text-ink-blue-5 underline"
28 >here</a
29 >.
30 </span>
31 <span v-if="deskEditUrl" class="flex items-center gap-1">
32 <a
33 :href="deskEditUrl"
34 target="_blank"
35 class="text-ink-blue-5 underline"
36 >
37 {{ __("Open in Desk") }}
38 </a>
39 </span>
40 </div>
41 </div>
42
43 <div class="flex flex-col gap-4">
44 <div class="grid grid-cols-1 gap-4">
45 <div
46 v-for="field in fields"
47 :key="field.name"
48 class="flex flex-col gap-1"
49 >
50 <div v-if="field.name === 'domain'" class="flex flex-col gap-2">
51 <Link
52 v-model="customState.domain"
53 :label="field.label"
54 :placeholder="field.placeholder"
55 doctype="Email Domain"
56 :onCreate="handleCreateDomainClick"
57 />
58 <div v-if="customState.domain" class="flex gap-2">
59 <Button
60 size="sm"
61 variant="subtle"
62 theme="gray"
63 :label="__('Edit Domain')"
64 @click="handleEditDomainClick"
65 />
66 </div>
67 </div>
68 <FormControl
69 v-else
70 v-model="state[field.name]"
71 :label="field.label"
72 :name="field.name"
73 :type="field.type"
74 :placeholder="field.placeholder"
75 />
76 </div>
77 </div>
78 <div
79 v-if="isCustomProvider"
80 class="grid grid-cols-1 md:grid-cols-2 gap-4"
81 >
82 <div class="flex flex-col gap-4">
83 <div
84 v-for="field in customIncomingFields"
85 :key="field.name"
86 class="flex flex-col gap-1"
87 >
88 <FormControl
89 v-model="customState[field.name]"
90 :label="field.label"
91 :name="field.name"
92 :type="field.type"
93 :placeholder="field.placeholder"
94 />
95 </div>
96 </div>
97 <div class="flex flex-col gap-4">
98 <div
99 v-for="field in customOutgoingFields"
100 :key="field.name"
101 class="flex flex-col gap-1"
102 >
103 <FormControl
104 v-model="customState[field.name]"
105 :label="field.label"
106 :name="field.name"
107 :type="field.type"
108 :placeholder="field.placeholder"
109 />
110 </div>
111 </div>
112 </div>
113 <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
114 <div
115 v-for="field in incomingOutgoingFields"
116 :key="field.name"
117 class="flex flex-col gap-1"
118 >
119 <FormControl
120 v-model="state[field.name]"
121 :label="field.label"
122 :name="field.name"
123 :type="field.type"
124 />
125 <p class="text-p-sm text-ink-gray-4">{{ field.description }}</p>
126 </div>
127 </div>
128 <ErrorMessage v-if="error" class="ms-1" :message="error" />
129 </div>
130 </div>
131
132 <div class="mt-auto flex justify-between -mb-8">
133 <Button
134 :label="__('Back')"
135 theme="gray"
136 variant="outline"
137 :disabled="loading"
138 @click="emit('update:step', 'email-list')"
139 />
140 <div class="flex gap-2">
141 <Button
142 v-if="hasChanges"
143 :label="__('Update Account')"
144 variant="solid"
145 @click="updateAccount"
146 :loading="loading"
147 />
148 <Button
149 v-if="accountData.enable_incoming"
150 :label="__('Pull Emails')"
151 variant="subtle"
152 @click="pullEmails"
153 :loading="loadingPull"
154 :disabled="loading"
155 />
156 </div>
157 </div>
158 </div>
159 </template>
160 </SettingsLayoutBase>
161</template>
162
163<script setup lang="ts">
164import { Link } from "@/components";
165import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
166import { __ } from "@/translation";
167import { EmailAccount, EmailStep } from "@/types";
168import { useStorage } from "@vueuse/core";
169import { call, toast } from "frappe-ui";
170import { computed, h, reactive, ref, watch } from "vue";
171import CircleAlert from "~icons/lucide/circle-alert";
172import EmailProviderIcon from "./EmailProviderIcon.vue";
173import {
174 customIncomingFields,
175 customOutgoingFields,
176 customProviderTopFields,
177 emailIcon,
178 frappeMailFields,
179 incomingOutgoingFields,
180 popularProviderFields,
181 services,
182 validateInputs,
183} from "./emailConfig";
184
185interface EmailAccountBaseState {
186 email_account_name: string;
187 service: string;
188 email_id: string;
189 enable_incoming: boolean;
190 enable_outgoing: boolean;
191 default_outgoing: boolean;
192 default_incoming: boolean;
193}
194
195interface EmailAccountProviderAuthState extends EmailAccountBaseState {
196 api_key?: string | null;
197 api_secret?: string | null;
198 password?: string | null;
199 frappe_mail_site?: string;
200}
201
202type CustomEmailAccountState = {
203 domain?: string;
204 email_server?: string;
205 smtp_server?: string;
206 incoming_port?: string | number;
207 smtp_port?: string | number;
208 use_ssl?: boolean | number;
209 use_starttls?: boolean | number;
210 use_tls?: boolean | number;
211 use_ssl_for_outgoing?: boolean | number;
212 validate_ssl_certificate?: boolean | number;
213 validate_ssl_certificate_for_outgoing?: boolean | number;
214 attachment_limit?: string | number;
215 append_emails_to_sent_folder?: boolean | number;
216 sent_folder_name?: string;
217};
218
219interface P {
220 accountData: EmailAccount;
221}
222
223interface E {
224 (event: "update:step", step: EmailStep): void;
225}
226
227const props = withDefaults(defineProps<P>(), {
228 accountData: null,
229});
230
231const emit = defineEmits<E>();
232
233const getInitialState = (): EmailAccountProviderAuthState => ({
234 email_account_name: props.accountData.email_account_name || "",
235 service: props.accountData.service || "",
236 email_id: props.accountData.email_id || "",
237 api_key: props.accountData?.api_key || null,
238 api_secret: props.accountData?.api_secret || null,
239 password: props.accountData?.password || null,
240 frappe_mail_site: props.accountData?.frappe_mail_site || "",
241 enable_incoming: props.accountData.enable_incoming || false,
242 enable_outgoing: props.accountData.enable_outgoing || false,
243 default_outgoing: props.accountData.default_outgoing || false,
244 default_incoming: props.accountData.default_incoming || false,
245});
246
247const state = reactive<EmailAccountProviderAuthState>(getInitialState());
248
249const getInitialCustomState = (): CustomEmailAccountState => ({
250 domain: props.accountData?.domain || "",
251 email_server: props.accountData?.email_server || "",
252 smtp_server: props.accountData?.smtp_server || "",
253 incoming_port: props.accountData?.incoming_port || "",
254 smtp_port: props.accountData?.smtp_port || "",
255 use_ssl: props.accountData?.use_ssl || false,
256 use_starttls: props.accountData?.use_starttls || false,
257 use_tls: props.accountData?.use_tls || false,
258 use_ssl_for_outgoing: props.accountData?.use_ssl_for_outgoing || false,
259 validate_ssl_certificate: props.accountData?.validate_ssl_certificate ?? true,
260 validate_ssl_certificate_for_outgoing:
261 props.accountData?.validate_ssl_certificate_for_outgoing ?? true,
262 attachment_limit: (props.accountData as any)?.attachment_limit || "",
263 append_emails_to_sent_folder:
264 (props.accountData as any)?.append_emails_to_sent_folder || false,
265 sent_folder_name: (props.accountData as any)?.sent_folder_name || "",
266});
267
268const customState = reactive<CustomEmailAccountState>(getInitialCustomState());
269
270const info = {
271 description: __("To know more about setting up email accounts, click"),
272 link: "https://github.com/ikhsanddy/opendesk",
273};
274
275const deskEditUrl = computed(() => {
276 const name = state.email_account_name || props.accountData.email_account_name;
277 if (!name) return "";
278 return `/desk/email-account/${encodeURIComponent(name)}`;
279});
280
281const currentServiceName = computed(
282 () => state.service || props.accountData.service || ""
283);
284
285const serviceDef = computed(() => {
286 const currentService = currentServiceName.value;
287 return services.find((s) => s.name === currentService);
288});
289
290const isCustomProvider = computed(() => {
291 const currentService = currentServiceName.value;
292 if (currentService === "Custom") {
293 return true;
294 }
295 if (currentService) {
296 return false;
297 }
298 // Many existing custom accounts store service as blank; detect by server fields.
299 return Boolean(
300 customState.email_server ||
301 customState.smtp_server ||
302 props.accountData.email_server ||
303 props.accountData.smtp_server
304 );
305});
306
307const fields = computed(() => {
308 if (isCustomProvider.value) {
309 return customProviderTopFields;
310 }
311 if (!serviceDef.value) {
312 return popularProviderFields;
313 }
314 if (serviceDef.value.name === "Frappe Mail") {
315 return frappeMailFields;
316 }
317 return popularProviderFields;
318});
319
320const error = ref<string | undefined>();
321const loading = ref(false);
322const loadingPull = useStorage(
323 `loading-emails-${state.email_account_name}`,
324 false
325);
326
327async function updateAccount() {
328 const validationState = { ...state, ...customState };
329 const validationService = isCustomProvider.value
330 ? "Custom"
331 : currentServiceName.value;
332 error.value = validateInputs(validationState, validationService, true);
333 if (error.value) return;
334
335 if (nameChanged.value) {
336 try {
337 loading.value = true;
338 await callRenameDoc();
339 succesHandler();
340 } catch (err) {
341 errorHandler();
342 }
343 }
344 if (isDirty.value) {
345 try {
346 loading.value = true;
347 await callSetValue(buildUpdatePayload());
348 succesHandler();
349 } catch (err) {
350 errorHandler();
351 }
352 }
353}
354
355function buildUpdatePayload() {
356 const commonPayload = {
357 email_id: state.email_id,
358 service: state.service,
359 enable_incoming: state.enable_incoming,
360 enable_outgoing: state.enable_outgoing,
361 default_incoming: state.default_incoming,
362 default_outgoing: state.default_outgoing,
363 };
364
365 if (currentServiceName.value === "Frappe Mail") {
366 return {
367 ...commonPayload,
368 frappe_mail_site: state.frappe_mail_site,
369 api_key: state.api_key,
370 api_secret: state.api_secret,
371 };
372 }
373
374 if (isCustomProvider.value) {
375 return {
376 ...commonPayload,
377 password: state.password,
378 ...customState,
379 };
380 }
381
382 return {
383 ...commonPayload,
384 password: state.password,
385 };
386}
387
388function pullEmails() {
389 loadingPull.value = true;
390
391 toast(__("Pulling emails, this may take a few minutes."), {
392 icon: () => h(CircleAlert, { class: "text-ink-blue-5" }),
393 });
394
395 call("frappe.email.doctype.email_account.email_account.pull_emails", {
396 email_account: state.email_account_name,
397 })
398 .then(() => {
399 localStorage.removeItem(`loading-emails-${state.email_account_name}`);
400 loadingPull.value = null;
401 toast.success(__("Emails pulled successfully"));
402 })
403 .catch(() => {
404 localStorage.removeItem(`loading-emails-${state.email_account_name}`);
405 loadingPull.value = null;
406 error.value = __("Failed to pull emails");
407 });
408}
409
410// Compare against the initial values rather than the account itself. The form
411// normalises what the server sends (a 0 becomes false, a null becomes ""), so
412// comparing straight to the account reported every custom account as changed.
413// Renaming goes through a separate API, so it is tracked by nameChanged.
414const isDirty = computed(() => {
415 const initialState = getInitialState();
416 const baseChanged = (
417 Object.keys(initialState) as (keyof EmailAccountProviderAuthState)[]
418 ).some(
419 (field) =>
420 field !== "email_account_name" && state[field] !== initialState[field]
421 );
422 if (baseChanged || !isCustomProvider.value) return baseChanged;
423
424 const initialCustomState = getInitialCustomState();
425 return (
426 Object.keys(initialCustomState) as (keyof CustomEmailAccountState)[]
427 ).some((field) => customState[field] !== initialCustomState[field]);
428});
429
430const nameChanged = computed(
431 () => state.email_account_name !== getInitialState().email_account_name
432);
433
434const hasChanges = computed(() => nameChanged.value || isDirty.value);
435
436async function callRenameDoc() {
437 return call("frappe.client.rename_doc", {
438 doctype: "Email Account",
439 old_name: props.accountData.email_account_name,
440 new_name: state.email_account_name,
441 });
442}
443
444async function callSetValue(values: Record<string, any>) {
445 const d = await call("frappe.client.set_value", {
446 doctype: "Email Account",
447 name: state.email_account_name,
448 fieldname: values,
449 });
450 return d.name;
451}
452
453function succesHandler() {
454 emit("update:step", "email-list");
455 toast.success(__("Email account updated successfully"));
456}
457
458function errorHandler() {
459 loading.value = false;
460 error.value = __("Failed to update email account, Invalid credentials");
461}
462
463watch(
464 () => props.accountData,
465 (val) => {
466 if (!val) return;
467 if (val.email_id && state.email_id !== val.email_id) {
468 state.email_id = val.email_id;
469 }
470 if (!customState.domain && val.domain) {
471 customState.domain = val.domain;
472 }
473 },
474 { deep: true, immediate: true }
475);
476
477watch(
478 () => customState.domain,
479 async (val) => {
480 if (!isCustomProvider.value) return;
481 if (!val) return;
482 try {
483 const doc = await call("frappe.client.get", {
484 doctype: "Email Domain",
485 name: val,
486 });
487 syncAccountFieldsFromDomain(doc);
488 } catch (err) {
489 console.warn("Failed to load Email Domain", err);
490 }
491 }
492);
493
494function syncAccountFieldsFromDomain(payload: Record<string, any>) {
495 customState.email_server = payload.email_server ?? customState.email_server;
496 customState.smtp_server = payload.smtp_server ?? customState.smtp_server;
497 customState.incoming_port =
498 payload.incoming_port ?? customState.incoming_port;
499 customState.smtp_port = payload.smtp_port ?? customState.smtp_port;
500 customState.use_ssl = Boolean(payload.use_ssl ?? customState.use_ssl);
501 customState.use_starttls = Boolean(
502 payload.use_starttls ?? customState.use_starttls
503 );
504 customState.use_tls = Boolean(payload.use_tls ?? customState.use_tls);
505 customState.use_ssl_for_outgoing = Boolean(
506 payload.use_ssl_for_outgoing ?? customState.use_ssl_for_outgoing
507 );
508 customState.validate_ssl_certificate = Boolean(
509 payload.validate_ssl_certificate ?? customState.validate_ssl_certificate
510 );
511 customState.validate_ssl_certificate_for_outgoing = Boolean(
512 payload.validate_ssl_certificate_for_outgoing ??
513 customState.validate_ssl_certificate_for_outgoing
514 );
515 customState.append_emails_to_sent_folder = Boolean(
516 payload.append_emails_to_sent_folder ??
517 customState.append_emails_to_sent_folder
518 );
519 customState.sent_folder_name =
520 payload.sent_folder_name || customState.sent_folder_name;
521 customState.attachment_limit =
522 payload.attachment_limit || customState.attachment_limit;
523}
524
525function handleCreateDomainClick(value: any, close?: () => void) {
526 close?.();
527 window.open(
528 "/desk/email-domain/new-email-domain",
529 "_blank",
530 "noopener,noreferrer"
531 );
532}
533
534function handleEditDomainClick() {
535 if (!customState.domain) return;
536 window.open(
537 `/desk/email-domain/${encodeURIComponent(customState.domain)}`,
538 "_blank",
539 "noopener,noreferrer"
540 );
541}
542</script>
543
544<style scoped></style>
545