From 748392ab7f56c36695bb2f1cec410f0aab5fad4b Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Wed, 5 Nov 2025 15:00:13 +0200 Subject: [PATCH] personal email folder mapping --- .../EmailAccount/FolderMap/Valid.php | 80 ++++++ .../RecordHooks/EmailAccount/BeforeSave.php | 99 ++++++++ .../Espo/Core/Mail/Account/Account.php | 8 + .../Espo/Core/Mail/Account/Fetcher.php | 49 +++- .../Mail/Account/GroupAccount/Account.php | 5 + .../Mail/Account/PersonalAccount/Account.php | 5 + application/Espo/Entities/EmailAccount.php | 14 ++ .../Resources/i18n/en_US/EmailAccount.json | 6 +- .../metadata/entityDefs/EmailAccount.json | 10 +- .../metadata/recordDefs/EmailAccount.json | 6 +- .../src/views/email-account/fields/folders.js | 236 +++++++++++++++++- .../src/views/inbound-email/fields/folders.js | 19 +- frontend/less/espo/elements/form.less | 27 ++ 13 files changed, 545 insertions(+), 19 deletions(-) create mode 100644 application/Espo/Classes/FieldValidators/EmailAccount/FolderMap/Valid.php create mode 100644 application/Espo/Classes/RecordHooks/EmailAccount/BeforeSave.php diff --git a/application/Espo/Classes/FieldValidators/EmailAccount/FolderMap/Valid.php b/application/Espo/Classes/FieldValidators/EmailAccount/FolderMap/Valid.php new file mode 100644 index 0000000000..71f055edb7 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/EmailAccount/FolderMap/Valid.php @@ -0,0 +1,80 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\FieldValidators\EmailAccount\FolderMap; + +use Espo\Core\FieldValidation\Validator; +use Espo\Core\FieldValidation\Validator\Data; +use Espo\Core\FieldValidation\Validator\Failure; +use Espo\Entities\EmailAccount; +use Espo\Entities\EmailFolder; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use stdClass; + +/** + * @implements Validator + */ +class Valid implements Validator +{ + public function __construct( + private EntityManager $entityManager, + ) {} + + public function validate(Entity $entity, string $field, Data $data): ?Failure + { + $map = $entity->get('folderMap'); + + if ($map === null) { + return null; + } + + if (!$map instanceof stdClass) { + return Failure::create(); + } + + foreach (get_object_vars($map) as $folderId) { + if ($folderId === null) { + continue; + } + + if (!is_string($folderId)) { + return Failure::create(); + } + + $folderEntry = $this->entityManager->getRepositoryByClass(EmailFolder::class)->getById($folderId); + + if (!$folderEntry) { + return Failure::create(); + } + } + + return null; + } +} diff --git a/application/Espo/Classes/RecordHooks/EmailAccount/BeforeSave.php b/application/Espo/Classes/RecordHooks/EmailAccount/BeforeSave.php new file mode 100644 index 0000000000..9d68012b54 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/EmailAccount/BeforeSave.php @@ -0,0 +1,99 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\RecordHooks\EmailAccount; + +use Espo\Core\Acl; +use Espo\Core\Exceptions\Error\Body; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\Hook\SaveHook; +use Espo\Entities\EmailAccount; +use Espo\Entities\EmailFolder; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; +use RuntimeException; +use stdClass; + +/** + * @implements SaveHook + */ +class BeforeSave implements SaveHook +{ + public function __construct( + private EntityManager $entityManager, + private Acl $acl, + ) {} + + public function process(Entity $entity): void + { + $this->checkFolderMap($entity); + } + + /** + * @throws Forbidden + */ + private function checkFolderMap(EmailAccount $entity): void + { + if (!$entity->isAttributeChanged('folderMap')) { + return; + } + + $map = $entity->get('folderMap'); + + if ($map === null) { + return; + } + + if (!$map instanceof stdClass) { + throw new RuntimeException("Bad folderMap."); + } + + foreach (get_object_vars($map) as $folderId) { + if ($folderId === null) { + continue; + } + + if (!is_string($folderId)) { + throw new RuntimeException("Bad folderMap item."); + } + + $folderEntry = $this->entityManager->getRepositoryByClass(EmailFolder::class)->getById($folderId); + + if (!$folderEntry) { + throw Forbidden::createWithBody('noFolder', + Body::create()->withMessageTranslation('noFolder', EmailAccount::ENTITY_TYPE) + ); + } + + if (!$this->acl->checkEntityRead($folderEntry)) { + throw new Forbidden("No access to mapped folder $folderId."); + } + } + } +} diff --git a/application/Espo/Core/Mail/Account/Account.php b/application/Espo/Core/Mail/Account/Account.php index 8fe3ac4aa8..26a15cda45 100644 --- a/application/Espo/Core/Mail/Account/Account.php +++ b/application/Espo/Core/Mail/Account/Account.php @@ -164,4 +164,12 @@ interface Account * Get the last connection time; */ public function getConnectedAt(): ?DateTime; + + + /** + * Get an email folder mapped to the email folder. + * + * @since 9.3.0 + */ + public function getMappedEmailFolder(string $folder): ?Link; } diff --git a/application/Espo/Core/Mail/Account/Fetcher.php b/application/Espo/Core/Mail/Account/Fetcher.php index fe7f75d389..63155be6e0 100644 --- a/application/Espo/Core/Mail/Account/Fetcher.php +++ b/application/Espo/Core/Mail/Account/Fetcher.php @@ -105,7 +105,7 @@ class Fetcher Account $account, string $folderOriginal, Storage $storage, - Collection $filterList + Collection $filterList, ): void { $fetchData = $account->getFetchData(); @@ -132,11 +132,11 @@ class Fetcher $previousLastUniqueId = $lastUniqueId; $idList = $this->getIdList( - $account, - $storage, - $lastUniqueId, - $lastDate, - $forceByDate + account: $account, + storage: $storage, + lastUID: $lastUniqueId, + lastDate: $lastDate, + forceByDate: $forceByDate, ); if (count($idList) === 1 && $lastUniqueId) { @@ -162,7 +162,13 @@ class Fetcher } } - $email = $this->fetchEmail($account, $storage, $id, $filterList); + $email = $this->fetchEmail( + account: $account, + storage: $storage, + id: $id, + filterList: $filterList, + mappedEmailFolderId: $account->getMappedEmailFolder($folderOriginal)?->getId(), + ); $isLast = $counter === count($idList) - 1; $isLastInPortion = $counter === $portionLimit - 1; @@ -254,7 +260,8 @@ class Fetcher Account $account, Storage $storage, int $id, - Collection $filterList + Collection $filterList, + ?string $mappedEmailFolderId, ): ?Email { $teamIdList = $account->getTeams()->getIdList(); @@ -265,11 +272,7 @@ class Fetcher $fetchOnlyHeader = $this->checkFetchOnlyHeader($storage, $id); - $folderData = []; - - if ($userId && $account->getEmailFolder()) { - $folderData[$userId] = $account->getEmailFolder()->getId(); - } + $folderData = $this->prepareFolderData($userId, $mappedEmailFolderId, $account); $flags = null; @@ -456,4 +459,24 @@ class Fetcher array_diff($flags, [Flag::RECENT]) ); } + + /** + * @return array + */ + private function prepareFolderData(?string $userId, ?string $mappedEmailFolderId, Account $account): array + { + if (!$userId) { + return []; + } + + $folderData = []; + + if ($mappedEmailFolderId) { + $folderData[$userId] = $mappedEmailFolderId; + } else if ($account->getEmailFolder()) { + $folderData[$userId] = $account->getEmailFolder()->getId(); + } + + return $folderData; + } } diff --git a/application/Espo/Core/Mail/Account/GroupAccount/Account.php b/application/Espo/Core/Mail/Account/GroupAccount/Account.php index 66e15eee36..c0b3a6ff52 100644 --- a/application/Espo/Core/Mail/Account/GroupAccount/Account.php +++ b/application/Espo/Core/Mail/Account/GroupAccount/Account.php @@ -381,4 +381,9 @@ class Account implements AccountInterface /** @var DateTime */ return $this->entity->getValueObject('connectedAt'); } + + public function getMappedEmailFolder(string $folder): ?Link + { + return null; + } } diff --git a/application/Espo/Core/Mail/Account/PersonalAccount/Account.php b/application/Espo/Core/Mail/Account/PersonalAccount/Account.php index 17649f1580..9b4ae591d1 100644 --- a/application/Espo/Core/Mail/Account/PersonalAccount/Account.php +++ b/application/Espo/Core/Mail/Account/PersonalAccount/Account.php @@ -201,6 +201,11 @@ class Account implements AccountInterface return $this->entity->getEmailFolder(); } + public function getMappedEmailFolder(string $folder): ?Link + { + return $this->entity->getMappedEmailFolder($folder); + } + /** * @return string[] */ diff --git a/application/Espo/Entities/EmailAccount.php b/application/Espo/Entities/EmailAccount.php index eec0d10c19..705a4ce6f8 100644 --- a/application/Espo/Entities/EmailAccount.php +++ b/application/Espo/Entities/EmailAccount.php @@ -117,6 +117,20 @@ class EmailAccount extends Entity return $this->getValueObject('emailFolder'); } + public function getMappedEmailFolder(string $folder): ?Link + { + /** @var stdClass $map */ + $map = $this->get('folderMap') ?? (object) []; + + $folderId = $map->$folder ?? null; + + if (!$folderId) { + return null; + } + + return Link::create($folderId); + } + /** * @return string[] */ diff --git a/application/Espo/Resources/i18n/en_US/EmailAccount.json b/application/Espo/Resources/i18n/en_US/EmailAccount.json index f7c692a40a..2c2ac2c337 100644 --- a/application/Espo/Resources/i18n/en_US/EmailAccount.json +++ b/application/Espo/Resources/i18n/en_US/EmailAccount.json @@ -23,7 +23,8 @@ "smtpSecurity": "SMTP Security", "smtpAuthMechanism": "SMTP Auth Mechanism", "smtpUsername": "SMTP Username", - "smtpPassword": "SMTP Password" + "smtpPassword": "SMTP Password", + "folderMap": "Folder Mapping" }, "links": { "filters": "Filters", @@ -60,6 +61,7 @@ "active": "Active" }, "messages": { + "noFolder": "Mapped folder does not exist.", "couldNotConnectToImap": "Could not connect to IMAP server", "connectionIsOk": "Connection is Ok", "imapNotConnected": "Could not connect to [IMAP account](#EmailAccount/view/{id})." @@ -67,7 +69,7 @@ "tooltips": { "useSmtp": "The ability to send emails.", "emailAddress": "The user record (assigned user) should have the same email address to be able to use this email account for sending.", - "monitoredFolders": "Multiple folders should be separated by comma.\n\nYou can add a 'Sent' folder to sync emails sent from an external email client.", + "monitoredFolders": "Select IMAP folders to import. Optionally, map them to Espo folders.\n\nYou can add the 'Sent' folder to sync emails sent from an external email client.", "storeSentEmails": "Sent emails will be stored on the IMAP server. Email Address field should match the address emails will be sent from." } } diff --git a/application/Espo/Resources/metadata/entityDefs/EmailAccount.json b/application/Espo/Resources/metadata/entityDefs/EmailAccount.json index 48d16cde28..7065811149 100644 --- a/application/Espo/Resources/metadata/entityDefs/EmailAccount.json +++ b/application/Espo/Resources/metadata/entityDefs/EmailAccount.json @@ -47,13 +47,21 @@ "view": "views/email-account/fields/folders", "displayAsList": true, "noEmptyString": true, - "duplicateIgnore": true + "duplicateIgnore": true, + "tooltip": true, + "fullNameAdditionalAttributeList": ["folderMap"] }, "sentFolder": { "type": "varchar", "view": "views/email-account/fields/folder", "duplicateIgnore": true }, + "folderMap": { + "type": "jsonObject", + "validatorClassNameList": [ + "Espo\\Classes\\FieldValidators\\EmailAccount\\FolderMap\\Valid" + ] + }, "storeSentEmails": { "type": "bool", "tooltip": true diff --git a/application/Espo/Resources/metadata/recordDefs/EmailAccount.json b/application/Espo/Resources/metadata/recordDefs/EmailAccount.json index 209dcff3d6..8942d1fd84 100644 --- a/application/Espo/Resources/metadata/recordDefs/EmailAccount.json +++ b/application/Espo/Resources/metadata/recordDefs/EmailAccount.json @@ -14,6 +14,10 @@ "Espo\\Classes\\Record\\InboundEmail\\PasswordsInputFilter" ], "beforeCreateHookClassNameList": [ - "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeCreate" + "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeCreate", + "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeSave" + ], + "beforeUpdateHookClassNameList": [ + "Espo\\Classes\\RecordHooks\\EmailAccount\\BeforeSave" ] } diff --git a/client/src/views/email-account/fields/folders.js b/client/src/views/email-account/fields/folders.js index 63713a1cc9..bc286fc983 100644 --- a/client/src/views/email-account/fields/folders.js +++ b/client/src/views/email-account/fields/folders.js @@ -28,14 +28,108 @@ import ArrayFieldView from 'views/fields/array'; -export default class extends ArrayFieldView { +export default class EmailAccountFoldersFieldView extends ArrayFieldView { + // language=Handlebars + detailTemplateContent = ` + {{#unless isEmpty}} + {{#each itemDataList}} +
+ {{value}} + {{~#if folderLabel}} + · {{folderLabel}} + {{/if~}} +
+ {{/each}} + {{else}} + {{~#if valueIsSet~}} + {{translate 'None'}}{{else}} + {{~/if~}} + {{/unless}} + + ` + + /** + * @protected + * @type {boolean} + */ + noFolderMap = false + + /** + * @protected + * @type {string} + */ getFoldersUrl = 'EmailAccount/action/getFolders' + /** + * @private + * @type {{id: string, name: string}[]} + */ + folderDataList + + getAttributeList() { + return [ + ...super.getAttributeList(), + 'folderMap', + ]; + } + + data() { + return { + ...super.data(), + itemDataList: this.getItemDataList(), + }; + } + + setup() { + super.setup(); + + if (!this.noFolderMap) { + this.loadEmailFolders(); + + this.listenTo(this.model, 'change:assignedUserId', () => this.loadEmailFolders()); + } + + this.addHandler('change', 'select[data-role="folderId"]', () => { + this.trigger('change'); + }) + } + + /** + * @private + * @return {Promise} + */ + async loadEmailFolders() { + this.folderDataList = await this.fetchEmailFolders(); + + await this.whenRendered(); + await this.reRender(); + } + setupOptions() { this.params.options = ['INBOX']; } + /** + * @private + * @return {Record[]} + */ + getItemDataList() { + /** @type {string[]} */ + const values = this.model.attributes[this.name] ?? []; + + return values.map(value => { + const folderId = this.getItemMappedFolderId(value); + + const item = this.folderDataList?.find(it => it.id === folderId); + + return { + value: value, + folderLabel: item?.name, + }; + }); + } + fetchFolders() { return new Promise(resolve => { const data = { @@ -69,6 +163,108 @@ export default class extends ArrayFieldView { }); } + afterRender() { + super.afterRender(); + + if (this.isDetailMode()) { + + } + } + + getItemHtml(value) { + const html = super.getItemHtml(value); + + if (this.noFolderMap) { + return html; + } + + let folderDataList = this.folderDataList; + + const folderId = this.getItemMappedFolderId(value); + + if (!folderDataList && folderId) { + folderDataList = [{id: folderId, name: folderId}]; + } + + if (!folderDataList) { + return html; + } + + const div = document.createElement('div'); + div.innerHTML = html; + + /** @type {HTMLElement} */ + const item = div.querySelector('.list-group-item'); + + const group = document.createElement('div'); + group.classList.add('item-input-container'); + + const select = document.createElement('select'); + select.className = 'form-control native-select input-sm'; + select.dataset.role = 'folderId'; + + select.append( + (() => { + return document.createElement('option'); + })() + ); + + for (const item of folderDataList) { + const option = document.createElement('option'); + + option.value = item.id; + option.text = item.name; + + if (folderId && item.id === folderId) { + option.selected = true; + option.setAttribute('selected', 'selected'); + } + + select.append(option) + } + + group.append(select); + item.append(group); + + return div.innerHTML; + } + + /** + * @protected + * @return Promise.<{id: string, name: string}[]> + */ + async fetchEmailFolders() { + if (!this.model.attributes.assignedUserId) { + return []; + } + + const collection = await this.getCollectionFactory().create('EmailFolder'); + + collection.data.select = ['id', 'name'].join(','); + collection.where = [ + { + attribute: 'assignedUserId', + type: 'equals', + value: this.model.attributes.assignedUserId, + } + ]; + + await collection.fetch(); + + return collection.models.map(m => ({id: m.id, name: m.attributes.name})); + } + + /** + * @private + * @param {string|null} folder + */ + getItemMappedFolderId(folder) { + const map = /** @type {Record.} */ + this.model.attributes.folderMap ?? {}; + + return map[folder] ?? null; + } + actionAddItem() { Espo.Ui.notifyWait(); @@ -96,4 +292,42 @@ export default class extends ArrayFieldView { }); }); } + + fetch() { + const data = super.fetch(); + + if (this.noFolderMap) { + return data; + } + + const map = {}; + + /** @type {string[]} */ + const folders = data[this.name] ?? []; + + /** @type {HTMLElement[]} */ + const items = Array.from(this.element?.querySelectorAll('.list-group-item') ?? []); + + for (const folder of folders) { + const item = items.find(div => div.dataset.value === folder); + + if (!item) { + continue; + } + + /** @type {HTMLSelectElement|null} */ + const select = item.querySelector('select[data-role="folderId"]'); + + if (!select) { + continue; + } + + map[folder] = select.value !== '' ? select.value : null; + } + + return { + ...super.fetch(), + folderMap: map, + }; + } } diff --git a/client/src/views/inbound-email/fields/folders.js b/client/src/views/inbound-email/fields/folders.js index 9f34ff923d..745b2b091b 100644 --- a/client/src/views/inbound-email/fields/folders.js +++ b/client/src/views/inbound-email/fields/folders.js @@ -28,8 +28,25 @@ import FoldersView from 'views/email-account/fields/folders'; -export default class extends FoldersView { +export default class InboundEmailGroupFolderFieldView extends FoldersView { // noinspection JSUnusedGlobalSymbols getFoldersUrl = 'InboundEmail/action/getFolders' + + // noinspection JSUnusedGlobalSymbols + noFolderMap = true + + /*async fetchEmailFolders() { + if (!this.model.attributes.assignedUserId) { + return []; + } + + const collection = await this.getCollectionFactory().create('GroupEmailFolder'); + + collection.data.select = ['id', 'name'].join(','); + + await collection.fetch(); + + return collection.models.map(m => ({id: m.id, name: m.attributes.name})); + }*/ } diff --git a/frontend/less/espo/elements/form.less b/frontend/less/espo/elements/form.less index acd6083715..87e6d6289c 100644 --- a/frontend/less/espo/elements/form.less +++ b/frontend/less/espo/elements/form.less @@ -615,6 +615,12 @@ select.form-control.native-select { &:hover { background-color: var(--dropdown-link-hover-bg); } + + &:empty { + &::checkmark { + display: none; + } + } } &::picker-icon { @@ -648,6 +654,14 @@ select.form-control.native-select { &:has(option.text-primary:checked) { color: var(--state-primary-text); } + + &.input-sm { + line-height: var(--20px); + + &::picker-icon { + top: var(--minus-1px); + } + } } .field { @@ -702,6 +716,19 @@ select.form-control.native-select { } } + &:has(> .item-input-container) { + > span.text { + width: calc(50%); + } + + > .item-input-container { + float: right; + display: inline-block; + width: calc(50% - var(--40px)); + padding-right: var(--5px); + } + } + &:has(> .item-button):has(> .drag-handle) { > span.text { width: calc(100% - var(--36px) - var(--18px));