1<template>
2 <SettingsLayoutBase
3 :title="__('Setup Email')"
4 :description="
5 __('Choose the email service provider you want to configure.')
6 "
7 >
8 <template #content>
9 <div class="flex h-full flex-col gap-4">
10 <div class="flex flex-col gap-4 overflow-y-auto p-0.5">
11 <!-- email service provider selection -->
12 <div class="flex flex-wrap items-center gap-4">
13 <div
14 v-for="s in services"
15 :key="s.name"
16 class="min-w-3 flex flex-col items-center gap-1"
17 @click="handleSelect(s)"
18 >
19 <EmailProviderIcon
20 :service-name="s.name"
21 :logo="s.icon"
22 :selected="selectedService?.name === s?.name"
23 />
24 </div>
25 </div>
26 <div v-if="selectedService" class="flex flex-col gap-4">
27 <!-- email service provider info -->
28 <div>
29 <div
30 class="flex items-center gap-2 rounded-5 p-2 ring-1 ring-outline-elevation-2"
31 >
32 <CircleAlert
33 class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-5"
34 />
35 <div class="text-wrap text-xs text-ink-gray-7">
36 {{ selectedService.info }}
37 <a
38 :href="selectedService.link"
39 target="_blank"
40 class="text-ink-blue-5 underline"
41 >here</a
42 >.
43 </div>
44 </div>
45 </div>
46 <!-- service provider fields -->
47 <div class="flex flex-col gap-4">
48 <div class="grid grid-cols-1 gap-4">
49 <div
50 v-for="field in fields"
51 :key="field.name"
52 class="flex flex-col gap-1"
53 >
54 <div
55 v-if="field.name === 'domain'"
56 class="flex flex-col gap-2"
57 >
58 <Link
59 v-model="customState.domain"
60 :label="field.label"
61 :placeholder="field.placeholder"
62 doctype="Email Domain"
63 :onCreate="handleCreateDomainClick"
64 />
65 <div v-if="customState.domain" class="flex gap-2">
66 <Button
67 size="sm"
68 variant="subtle"
69 theme="gray"
70 :label="__('Edit Domain')"
71 @click="handleEditDomainClick"
72 />
73 </div>
74 </div>
75 <FormControl
76 v-else
77 v-model="state[field.name]"
78 :label="field.label"
79 :name="field.name"
80 :type="field.type"
81 :placeholder="field.placeholder"
82 />
83 </div>
84 </div>
85 <div
86 v-if="isCustomSelected"
87 class="grid grid-cols-1 md:grid-cols-2 gap-4"
88 >
89 <div class="flex flex-col gap-4">
90 <div
91 v-for="field in customIncomingFields"
92 :key="field.name"
93 class="flex flex-col gap-1"
94 >
95 <FormControl
96 v-model="customState[field.name]"
97 :label="field.label"
98 :name="field.name"
99 :type="field.type"
100 :placeholder="field.placeholder"
101 />
102 </div>
103 </div>
104 <div class="flex flex-col gap-4">
105 <div
106 v-for="field in customOutgoingFields"
107 :key="field.name"
108 class="flex flex-col gap-1"
109 >
110 <FormControl
111 v-model="customState[field.name]"
112 :label="field.label"
113 :name="field.name"
114 :type="field.type"
115 :placeholder="field.placeholder"
116 />
117 </div>
118 </div>
119 </div>
120 <div class="grid grid-cols-2 gap-4">
121 <div
122 v-for="field in incomingOutgoingFields"
123 :key="field.name"
124 class="flex flex-col gap-1"
125 >
126 <FormControl
127 v-model="state[field.name]"
128 :label="field.label"
129 :name="field.name"
130 :type="field.type"
131 />
132 <p class="text-ink-gray-4 text-p-sm">
133 {{ field.description }}
134 </p>
135 </div>
136 </div>
137 <ErrorMessage v-if="error" class="ms-1" :message="error" />
138 </div>
139 </div>
140 </div>
141
142 <!-- action button -->
143 <div class="mt-auto flex justify-between -mb-8">
144 <Button
145 :label="__('Back')"
146 theme="gray"
147 variant="outline"
148 :disabled="addEmailRes.loading"
149 @click="emit('update:step', 'email-list')"
150 />
151 <Button
152 :label="__('Create')"
153 variant="solid"
154 :loading="addEmailRes.loading"
155 @click="createEmailAccount"
156 />
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 { capture } from "@/telemetry";
167import { __ } from "@/translation";
168import { EmailAccount, EmailService, EmailStep } from "@/types";
169import { call, createResource, toast } from "frappe-ui";
170import { useOnboarding } from "@framework/ui";
171import { computed, reactive, Ref, ref, watch } from "vue";
172import CircleAlert from "~icons/lucide/circle-alert";
173import {
174 customIncomingFields,
175 customOutgoingFields,
176 customProviderTopFields,
177 frappeMailFields,
178 incomingOutgoingFields,
179 popularProviderFields,
180 services,
181 validateInputs,
182} from "./emailConfig";
183import EmailProviderIcon from "./EmailProviderIcon.vue";
184
185interface EmailAccountBaseState {
186 email_account_name: string;
187 email_id: string;
188 service: string;
189 enable_incoming: boolean;
190 enable_outgoing: boolean;
191 default_incoming: boolean;
192 default_outgoing: boolean;
193}
194
195interface EmailAccountProviderAuthState extends EmailAccountBaseState {
196 password?: string;
197 api_key?: string;
198 api_secret?: string;
199 frappe_mail_site?: string;
200}
201
202type CustomEmailAccountState = {
203 domain?: string;
204 email_server?: string;
205 incoming_port?: string | number;
206 smtp_server?: string;
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 E {
220 (event: "update:step", value: EmailStep): void;
221}
222
223const emit = defineEmits<E>();
224
225const { updateOnboardingStep } = useOnboarding("opendesk") ?? {};
226
227const state = reactive<EmailAccountProviderAuthState>({
228 service: "",
229 email_account_name: "",
230 email_id: "",
231 password: "",
232 api_key: "",
233 api_secret: "",
234 frappe_mail_site: "",
235 enable_incoming: false,
236 enable_outgoing: false,
237 default_incoming: false,
238 default_outgoing: false,
239});
240
241const getDefaultCustomState = (): CustomEmailAccountState => ({
242 domain: "",
243 email_server: "",
244 smtp_server: "",
245 incoming_port: "",
246 smtp_port: "",
247 use_ssl: false,
248 use_starttls: false,
249 use_tls: false,
250 use_ssl_for_outgoing: false,
251 validate_ssl_certificate: true,
252 validate_ssl_certificate_for_outgoing: true,
253 attachment_limit: "",
254 append_emails_to_sent_folder: false,
255 sent_folder_name: "",
256});
257
258const customState = reactive<CustomEmailAccountState>(getDefaultCustomState());
259
260function resetCustomState() {
261 Object.assign(customState, getDefaultCustomState());
262}
263
264const selectedService: Ref<EmailService> = ref(null);
265const fields = computed(() => {
266 if (!selectedService.value) return [];
267 if (selectedService.value.name === "Frappe Mail") {
268 return frappeMailFields;
269 }
270 if (selectedService.value.custom) {
271 return customProviderTopFields;
272 }
273 return popularProviderFields;
274});
275
276const isCustomSelected = computed(
277 () => selectedService.value?.name === "Custom"
278);
279
280watch(
281 () => customState.domain,
282 async (val) => {
283 if (state.service !== "Custom") return;
284 if (!isCustomSelected.value || !val) return;
285 try {
286 const doc = await call("frappe.client.get", {
287 doctype: "Email Domain",
288 name: val,
289 });
290 syncAccountFieldsFromDomain(doc);
291 } catch (err) {
292 console.warn("Failed to load Email Domain", err);
293 }
294 }
295);
296
297function handleSelect(service: EmailService) {
298 selectedService.value = service;
299 state.service = service.name;
300 if (service.name !== "Custom") {
301 resetCustomState();
302 }
303}
304
305const addEmailRes = createResource({
306 url: "opendesk.api.settings.email.create_email_account",
307 makeParams: (val: EmailAccount) => ({ ...val }),
308 onSuccess: () => {
309 toast.success(__("Email account created"));
310 emit("update:step", "email-list");
311 updateOnboardingStep?.("setup_email_account");
312 capture("email_account_created", { data: { service: state.service } });
313 },
314 onError: () => {
315 error.value = __("Failed to create email account, Invalid credentials");
316 },
317});
318
319const error = ref<string | undefined>();
320function createEmailAccount() {
321 const validationState = { ...state, ...customState };
322 error.value = validateInputs(validationState, state.service);
323 if (error.value) return;
324
325 addEmailRes.submit({ data: buildCreatePayload() });
326}
327
328function buildCreatePayload() {
329 const commonPayload = {
330 email_account_name: state.email_account_name,
331 email_id: state.email_id,
332 service: state.service,
333 enable_incoming: state.enable_incoming,
334 enable_outgoing: state.enable_outgoing,
335 default_incoming: state.default_incoming,
336 default_outgoing: state.default_outgoing,
337 };
338
339 if (state.service === "Frappe Mail") {
340 return {
341 ...commonPayload,
342 frappe_mail_site: state.frappe_mail_site,
343 api_key: state.api_key,
344 api_secret: state.api_secret,
345 };
346 }
347
348 if (state.service === "Custom") {
349 return {
350 ...commonPayload,
351 password: state.password,
352 ...customState,
353 };
354 }
355
356 return {
357 ...commonPayload,
358 password: state.password,
359 };
360}
361
362function syncAccountFieldsFromDomain(payload: Record<string, any>) {
363 customState.email_server = payload.email_server ?? customState.email_server;
364 customState.smtp_server = payload.smtp_server ?? customState.smtp_server;
365 customState.incoming_port =
366 payload.incoming_port ?? customState.incoming_port;
367 customState.smtp_port = payload.smtp_port ?? customState.smtp_port;
368 customState.use_ssl = Boolean(payload.use_ssl ?? customState.use_ssl);
369 customState.use_starttls = Boolean(
370 payload.use_starttls ?? customState.use_starttls
371 );
372 customState.use_tls = Boolean(payload.use_tls ?? customState.use_tls);
373 customState.use_ssl_for_outgoing = Boolean(
374 payload.use_ssl_for_outgoing ?? customState.use_ssl_for_outgoing
375 );
376 customState.validate_ssl_certificate = Boolean(
377 payload.validate_ssl_certificate ?? customState.validate_ssl_certificate
378 );
379 customState.validate_ssl_certificate_for_outgoing = Boolean(
380 payload.validate_ssl_certificate_for_outgoing ??
381 customState.validate_ssl_certificate_for_outgoing
382 );
383 customState.append_emails_to_sent_folder = Boolean(
384 payload.append_emails_to_sent_folder ??
385 customState.append_emails_to_sent_folder
386 );
387 customState.sent_folder_name =
388 payload.sent_folder_name || customState.sent_folder_name;
389 customState.attachment_limit =
390 payload.attachment_limit || customState.attachment_limit;
391}
392
393function handleCreateDomainClick(value: any, close?: () => void) {
394 close?.();
395 window.open(
396 "/desk/email-domain/new-email-domain",
397 "_blank",
398 "noopener,noreferrer"
399 );
400}
401
402function handleEditDomainClick() {
403 if (!customState.domain) return;
404 window.open(
405 `/desk/email-domain/${encodeURIComponent(customState.domain)}`,
406 "_blank",
407 "noopener,noreferrer"
408 );
409}
410</script>
411
412<style scoped></style>
413