personal email folder mapping

This commit is contained in:
Yuri Kuznetsov
2025-11-05 15:00:13 +02:00
parent 683f86806f
commit 748392ab7f
13 changed files with 545 additions and 19 deletions
@@ -0,0 +1,80 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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<EmailAccount>
*/
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;
}
}
@@ -0,0 +1,99 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* 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<EmailAccount>
*/
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.");
}
}
}
}
@@ -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;
}
+36 -13
View File
@@ -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<string, string>
*/
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;
}
}
@@ -381,4 +381,9 @@ class Account implements AccountInterface
/** @var DateTime */
return $this->entity->getValueObject('connectedAt');
}
public function getMappedEmailFolder(string $folder): ?Link
{
return null;
}
}
@@ -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[]
*/
@@ -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[]
*/
@@ -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."
}
}
@@ -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
@@ -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"
]
}
@@ -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}}
<div class="multi-enum-item-container">
<span>{{value}}</span>
{{~#if folderLabel}}
<span class="text-muted small"><span> · </span>{{folderLabel}}</span>
{{/if~}}
</div>
{{/each}}
{{else}}
{{~#if valueIsSet~}}
<span class="none-value">{{translate 'None'}}</span>{{else}}<span class="loading-value"></span>
{{~/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<void>}
*/
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.<string, string>} */
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,
};
}
}
@@ -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}));
}*/
}
+27
View File
@@ -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));