feat: IMAP Email Channel (#3298)

This change allows the user to configure both IMAP and SMTP for an email inbox. IMAP enables the user to see emails in Chatwoot. And user can use SMTP to reply to an email conversation.

Users can use the default settings to send and receive emails for email inboxes if both IMAP and SMTP are disabled.

Fixes #2520
This commit is contained in:
Aswin Dev P.S
2021-11-18 22:22:27 -08:00
committed by GitHub
parent 8384d0b38e
commit 24e6a92297
25 changed files with 1040 additions and 57 deletions

View File

@@ -401,6 +401,65 @@
"VALIDATION_ERROR": "Starting time should be before closing time.",
"CHOOSE": "Choose"
}
},
"IMAP": {
"TITLE": "IMAP",
"SUBTITLE": "Set your IMAP details",
"UPDATE": "Update IMAP settings",
"TOGGLE_AVAILABILITY": "Enable IMAP configuration for this inbox",
"TOGGLE_HELP": "Enabling IMAP will help the user to recieve email",
"EDIT": {
"SUCCESS_MESSAGE": "IMAP settings updated successfully",
"ERROR_MESSAGE": "Unable to update IMAP settings"
},
"ADDRESS": {
"LABEL": "Address",
"PLACE_HOLDER": "Address (Eg: imap.gmail.com)"
},
"PORT": {
"LABEL": "Port",
"PLACE_HOLDER": "Port"
},
"EMAIL": {
"LABEL": "Email",
"PLACE_HOLDER": "Email"
},
"PASSWORD": {
"LABEL": "Password",
"PLACE_HOLDER": "Password"
},
"ENABLE_SSL": "Enable SSL"
},
"SMTP": {
"TITLE": "SMTP",
"SUBTITLE": "Set your SMTP details",
"UPDATE": "Update SMTP settings",
"TOGGLE_AVAILABILITY": "Enable SMTP configuration for this inbox",
"TOGGLE_HELP": "Enabling SMTP will help the user to send email",
"EDIT": {
"SUCCESS_MESSAGE": "SMTP settings updated successfully",
"ERROR_MESSAGE": "Unable to update SMTP settings"
},
"ADDRESS": {
"LABEL": "Address",
"PLACE_HOLDER": "Address (Eg: smtp.gmail.com)"
},
"PORT": {
"LABEL": "Port",
"PLACE_HOLDER": "Port"
},
"EMAIL": {
"LABEL": "Email",
"PLACE_HOLDER": "Email"
},
"PASSWORD": {
"LABEL": "Password",
"PLACE_HOLDER": "Password"
},
"DOMAIN": {
"LABEL": "Domain",
"PLACE_HOLDER": "Domain"
}
}
}
}

View File

@@ -0,0 +1,163 @@
<template>
<div class="settings--content">
<settings-section
:title="$t('INBOX_MGMT.IMAP.TITLE')"
:sub-title="$t('INBOX_MGMT.IMAP.SUBTITLE')"
>
<form @submit.prevent="updateInbox">
<label for="toggle-imap-enable">
<input
v-model="isIMAPEnabled"
type="checkbox"
name="toggle-imap-enable"
/>
{{ $t('INBOX_MGMT.IMAP.TOGGLE_AVAILABILITY') }}
</label>
<p>{{ $t('INBOX_MGMT.IMAP.TOGGLE_HELP') }}</p>
<div v-if="isIMAPEnabled" class="imap-details-wrap">
<woot-input
v-model.trim="address"
:class="{ error: $v.address.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.IMAP.ADDRESS.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.ADDRESS.PLACE_HOLDER')"
@blur="$v.address.$touch"
/>
<woot-input
v-model="port"
type="number"
:class="{ error: $v.port.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.IMAP.PORT.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.PORT.PLACE_HOLDER')"
@blur="$v.port.$touch"
/>
<woot-input
v-model="email"
:class="{ error: $v.email.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.IMAP.EMAIL.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.EMAIL.PLACE_HOLDER')"
@blur="$v.email.$touch"
/>
<woot-input
v-model="password"
:class="{ error: $v.password.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.IMAP.PASSWORD.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.PASSWORD.PLACE_HOLDER')"
type="password"
@blur="$v.password.$touch"
/>
<label for="toggle-enable-ssl">
<input
v-model="isSSLEnabled"
type="checkbox"
name="toggle-enable-ssl"
/>
{{ $t('INBOX_MGMT.IMAP.ENABLE_SSL') }}
</label>
</div>
<woot-submit-button
:button-text="$t('INBOX_MGMT.IMAP.UPDATE')"
:loading="uiFlags.isUpdatingInbox"
:disabled="($v.$invalid && isIMAPEnabled) || uiFlags.isUpdatingIMAP"
/>
</form>
</settings-section>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import SettingsSection from 'dashboard/components/SettingsSection';
import { required, minLength, email } from 'vuelidate/lib/validators';
export default {
components: {
SettingsSection,
},
mixins: [alertMixin],
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
isIMAPEnabled: false,
address: '',
port: '',
email: '',
password: '',
isSSLEnabled: true,
};
},
validations: {
address: { required },
port: { required, minLength: minLength(2) },
email: { required, email },
password: { required },
},
computed: {
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }),
},
watch: {
inbox() {
this.setDefaults();
},
},
mounted() {
this.setDefaults();
},
methods: {
setDefaults() {
const {
imap_enabled,
imap_address,
imap_port,
imap_email,
imap_password,
imap_enable_ssl,
} = this.inbox;
this.isIMAPEnabled = imap_enabled;
this.address = imap_address;
this.port = imap_port;
this.email = imap_email;
this.password = imap_password;
this.isSSLEnabled = imap_enable_ssl;
},
async updateInbox() {
try {
this.loading = true;
const payload = {
id: this.inbox.id,
formData: false,
channel: {
imap_enabled: this.isIMAPEnabled,
imap_address: this.address,
imap_port: this.port,
imap_email: this.email,
imap_password: this.password,
imap_enable_ssl: this.isSSLEnabled,
imap_inbox_synced_at: this.isIMAPEnabled
? new Date().toISOString()
: undefined,
},
};
await this.$store.dispatch('inboxes/updateInboxIMAP', payload);
this.showAlert(this.$t('INBOX_MGMT.IMAP.EDIT.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.IMAP.EDIT.ERROR_MESSAGE'));
}
},
},
};
</script>
<style lang="scss" scoped>
.imap-details-wrap {
margin-bottom: var(--space-medium);
}
</style>

View File

@@ -353,6 +353,8 @@
<woot-code :script="inbox.forward_to_email"></woot-code>
</settings-section>
</div>
<imap-settings :inbox="inbox" />
<smtp-settings :inbox="inbox" />
</div>
</div>
<div v-if="selectedTabKey === 'preChatForm'">
@@ -378,6 +380,8 @@ import FacebookReauthorize from './facebook/Reauthorize';
import PreChatFormSettings from './PreChatForm/Settings';
import WeeklyAvailability from './components/WeeklyAvailability';
import GreetingsEditor from 'shared/components/GreetingsEditor';
import ImapSettings from './ImapSettings';
import SmtpSettings from './SmtpSettings';
export default {
components: {
@@ -387,6 +391,8 @@ export default {
PreChatFormSettings,
WeeklyAvailability,
GreetingsEditor,
ImapSettings,
SmtpSettings,
},
mixins: [alertMixin, configMixin, inboxMixin],
data() {

View File

@@ -0,0 +1,163 @@
<template>
<div class="settings--content">
<settings-section
:title="$t('INBOX_MGMT.SMTP.TITLE')"
:sub-title="$t('INBOX_MGMT.SMTP.SUBTITLE')"
>
<form @submit.prevent="updateInbox">
<label for="toggle-enable-smtp">
<input
v-model="isSMTPEnabled"
type="checkbox"
name="toggle-enable-smtp"
/>
{{ $t('INBOX_MGMT.SMTP.TOGGLE_AVAILABILITY') }}
</label>
<p>{{ $t('INBOX_MGMT.SMTP.TOGGLE_HELP') }}</p>
<div v-if="isSMTPEnabled" class="smtp-details-wrap">
<woot-input
v-model.trim="address"
:class="{ error: $v.address.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.SMTP.ADDRESS.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.ADDRESS.PLACE_HOLDER')"
@blur="$v.address.$touch"
/>
<woot-input
v-model="port"
type="number"
:class="{ error: $v.port.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.SMTP.PORT.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.PORT.PLACE_HOLDER')"
@blur="$v.port.$touch"
/>
<woot-input
v-model="email"
:class="{ error: $v.email.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.SMTP.EMAIL.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.EMAIL.PLACE_HOLDER')"
@blur="$v.email.$touch"
/>
<woot-input
v-model="password"
:class="{ error: $v.password.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.SMTP.PASSWORD.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.PASSWORD.PLACE_HOLDER')"
type="password"
@blur="$v.password.$touch"
/>
<woot-input
v-model.trim="domain"
:class="{ error: $v.domain.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.SMTP.DOMAIN.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.DOMAIN.PLACE_HOLDER')"
@blur="$v.domain.$touch"
/>
</div>
<woot-submit-button
:button-text="$t('INBOX_MGMT.SMTP.UPDATE')"
:loading="uiFlags.isUpdatingInbox"
:disabled="($v.$invalid && isSMTPEnabled) || uiFlags.isUpdatingSMTP"
/>
</form>
</settings-section>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import SettingsSection from 'dashboard/components/SettingsSection';
import { required, minLength, email } from 'vuelidate/lib/validators';
export default {
components: {
SettingsSection,
},
mixins: [alertMixin],
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
isSMTPEnabled: false,
address: '',
port: '',
email: '',
password: '',
domain: '',
};
},
validations: {
address: { required },
port: {
required,
minLength: minLength(2),
},
email: { required, email },
password: { required },
domain: { required },
},
computed: {
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }),
},
watch: {
inbox() {
this.setDefaults();
},
},
mounted() {
this.setDefaults();
},
methods: {
setDefaults() {
const {
smtp_enabled,
smtp_address,
smtp_port,
smtp_email,
smtp_password,
smtp_domain,
} = this.inbox;
this.isSMTPEnabled = smtp_enabled;
this.address = smtp_address;
this.port = smtp_port;
this.email = smtp_email;
this.password = smtp_password;
this.domain = smtp_domain;
},
async updateInbox() {
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
smtp_enabled: this.isSMTPEnabled,
smtp_address: this.address,
smtp_port: this.port,
smtp_email: this.email,
smtp_password: this.password,
smtp_domain: this.domain,
},
};
await this.$store.dispatch('inboxes/updateInboxSMTP', payload);
this.showAlert(this.$t('INBOX_MGMT.SMTP.EDIT.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE'));
}
},
},
};
</script>
<style lang="scss" scoped>
.smtp-details-wrap {
margin-bottom: var(--space-medium);
}
</style>

View File

@@ -35,6 +35,8 @@ export const state = {
isUpdating: false,
isUpdatingAutoAssignment: false,
isDeleting: false,
isUpdatingIMAP: false,
isUpdatingSMTP: false,
},
};
@@ -164,6 +166,52 @@ export const actions = {
throw new Error(error);
}
},
updateInboxIMAP: async (
{ commit },
{ id, formData = true, ...inboxParams }
) => {
commit(types.default.SET_INBOXES_UI_FLAG, {
isUpdatingIMAP: true,
});
try {
const response = await InboxesAPI.update(
id,
formData ? buildInboxData(inboxParams) : inboxParams
);
commit(types.default.EDIT_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, {
isUpdatingIMAP: false,
});
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, {
isUpdatingIMAP: false,
});
throw new Error(error);
}
},
updateInboxSMTP: async (
{ commit },
{ id, formData = true, ...inboxParams }
) => {
commit(types.default.SET_INBOXES_UI_FLAG, {
isUpdatingSMTP: true,
});
try {
const response = await InboxesAPI.update(
id,
formData ? buildInboxData(inboxParams) : inboxParams
);
commit(types.default.EDIT_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, {
isUpdatingSMTP: false,
});
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, {
isUpdatingSMTP: false,
});
throw new Error(error);
}
},
delete: async ({ commit }, inboxId) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: true });
try {

View File

@@ -107,6 +107,66 @@ describe('#actions', () => {
});
});
describe('#updateInboxIMAP', () => {
it('sends correct actions if API is success', async () => {
const updatedInbox = inboxList[0];
axios.patch.mockResolvedValue({ data: updatedInbox });
await actions.updateInboxIMAP(
{ commit },
{ id: updatedInbox.id, inbox: { channel: { imap_enabled: true } } }
);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: true }],
[types.default.EDIT_INBOXES, updatedInbox],
[types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.updateInboxIMAP(
{ commit },
{ id: inboxList[0].id, inbox: { channel: { imap_enabled: true } } }
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: true }],
[types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: false }],
]);
});
});
describe('#updateInboxSMTP', () => {
it('sends correct actions if API is success', async () => {
const updatedInbox = inboxList[0];
axios.patch.mockResolvedValue({ data: updatedInbox });
await actions.updateInboxSMTP(
{ commit },
{ id: updatedInbox.id, inbox: { channel: { smtp_enabled: true } } }
);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: true }],
[types.default.EDIT_INBOXES, updatedInbox],
[types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.updateInboxSMTP(
{ commit },
{ id: inboxList[0].id, inbox: { channel: { smtp_enabled: true } } }
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: true }],
[types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: false }],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({ data: inboxList[0] });