Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/mail.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKristian Lebold <kristian@lebold.info>2021-02-15 16:35:45 +0300
committerChristoph Wurst <christoph@winzerhof-wurst.at>2021-03-12 17:05:26 +0300
commitb3b874380dbedfed0a2331ec8b65700da3fe5c74 (patch)
tree1ecc7bfdffdfb5fc1ad514cc35ef3b33f5aa698c
parentb4aacab9706d0c1dc3fdc2011b016a57f32cd8f5 (diff)
Allow adding senders to the address book
closes #4458 Signed-off-by: Kristian Lebold <kristian@lebold.info> Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
-rw-r--r--appinfo/routes.php20
-rw-r--r--lib/Controller/ContactIntegrationController.php99
-rw-r--r--lib/Service/ContactIntegration/ContactIntegrationService.php53
-rw-r--r--lib/Service/ContactsIntegration.php105
-rw-r--r--src/components/RecipientBubble.vue189
-rw-r--r--src/service/ContactIntegrationService.js51
-rw-r--r--tests/psalm-baseline.xml5
7 files changed, 516 insertions, 6 deletions
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 0f538e786..d25b478db 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -90,6 +90,26 @@ return [
'verb' => 'GET'
],
[
+ 'name' => 'contactIntegration#autoComplete',
+ 'url' => '/api/contactIntegration/autoComplete/{term}',
+ 'verb' => 'GET'
+ ],
+ [
+ 'name' => 'contactIntegration#addMail',
+ 'url' => '/api/contactIntegration/add',
+ 'verb' => 'PUT'
+ ],
+ [
+ 'name' => 'contactIntegration#newContact',
+ 'url' => '/api/contactIntegration/new',
+ 'verb' => 'PUT'
+ ],
+ [
+ 'name' => 'contactIntegration#match',
+ 'url' => '/api/contactIntegration/match/{mail}',
+ 'verb' => 'GET'
+ ],
+ [
'name' => 'mailboxes#patch',
'url' => '/api/mailboxes/{id}',
'verb' => 'PATCH'
diff --git a/lib/Controller/ContactIntegrationController.php b/lib/Controller/ContactIntegrationController.php
new file mode 100644
index 000000000..81f49bb7a
--- /dev/null
+++ b/lib/Controller/ContactIntegrationController.php
@@ -0,0 +1,99 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @author Kristian Lebold <kristian@lebold.info>
+ *
+ * Mail
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * 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, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\Mail\Controller;
+
+use OCA\Mail\Service\ContactIntegration\ContactIntegrationService;
+use OCP\AppFramework\Controller;
+use OCP\AppFramework\Http;
+use OCP\AppFramework\Http\JSONResponse;
+use OCP\IRequest;
+
+class ContactIntegrationController extends Controller {
+
+ /** @var ContactIntegrationService */
+ private $service;
+
+ public function __construct(string $appName,
+ IRequest $request,
+ ContactIntegrationService $service) {
+ parent::__construct($appName, $request);
+
+ $this->service = $service;
+ }
+
+ /**
+ * @NoAdminRequired
+ * @TrapError
+ *
+ * @param string $mail
+ * @return JSONResponse
+ */
+ public function match(string $mail): JSONResponse {
+ return new JSONResponse($this->service->findMatches($mail));
+ }
+
+ /**
+ * @NoAdminRequired
+ * @TrapError
+ *
+ * @param string $uid
+ * @param string $mail
+ * @return JSONResponse
+ */
+ public function addMail(string $uid = null, string $mail = null): JSONResponse {
+ $res = $this->service->addEMailToContact($uid, $mail);
+ if ($res === null) {
+ return new JSONResponse([], Http::STATUS_NOT_FOUND);
+ }
+ return new JSONResponse($res);
+ }
+
+ /**
+ * @NoAdminRequired
+ * @TrapError
+ *
+ * @param string $name
+ * @param string $mail
+ * @return JSONResponse
+ */
+ public function newContact(string $contactName = null, string $mail = null): JSONResponse {
+ $res = $this->service->newContact($contactName, $mail);
+ if ($res === null) {
+ return new JSONResponse([], Http::STATUS_NOT_ACCEPTABLE);
+ }
+ return new JSONResponse($res);
+ }
+
+ /**
+ * @NoAdminRequired
+ * @TrapError
+ *
+ * @param string $term
+ * @return JSONResponse
+ */
+ public function autoComplete(string $term): JSONResponse {
+ $res = $this->service->autoComplete($term);
+ return new JSONResponse($res);
+ }
+}
diff --git a/lib/Service/ContactIntegration/ContactIntegrationService.php b/lib/Service/ContactIntegration/ContactIntegrationService.php
new file mode 100644
index 000000000..3579a3ebd
--- /dev/null
+++ b/lib/Service/ContactIntegration/ContactIntegrationService.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @author Kristian Lebold <kristian@lebold.info>
+ *
+ * Mail
+ *
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * 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, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\Mail\Service\ContactIntegration;
+
+use OCA\Mail\Service\ContactsIntegration;
+
+class ContactIntegrationService {
+
+ /** @var ContactsIntegration */
+ private $contactsIntegration;
+
+ public function __construct(ContactsIntegration $ci) {
+ $this->contactsIntegration = $ci;
+ }
+
+ public function findMatches(string $mail): array {
+ $matches = $this->contactsIntegration->getContactsWithMail($mail);
+ return $matches;
+ }
+
+ public function addEMailToContact(string $uid, string $mail): ?array {
+ return $this->contactsIntegration->addEmailToContact($uid, $mail);
+ }
+
+ public function newContact(string $name, string $mail): ?array {
+ return $this->contactsIntegration->newContact($name, $mail);
+ }
+
+ public function autoComplete(string $term): array {
+ return $this->contactsIntegration->getContactsWithName($term);
+ }
+}
diff --git a/lib/Service/ContactsIntegration.php b/lib/Service/ContactsIntegration.php
index 2f5a1f139..52c32582f 100644
--- a/lib/Service/ContactsIntegration.php
+++ b/lib/Service/ContactsIntegration.php
@@ -118,4 +118,109 @@ class ContactsIntegration {
return null;
}
}
+
+ /**
+ * Adds a new email to an existing Contact
+ *
+ * @param string $uid
+ * @param string $mailAddr
+ * @param string $type
+ * @return array|null
+ */
+ public function addEmailToContact(string $uid, string $mailAddr, string $type = 'HOME') {
+ if (!$this->contactsManager->isEnabled()) {
+ return null;
+ }
+
+ $result = $this->contactsManager->search($uid, ['UID'], ['types' => true, 'limit' => 1]);
+
+ if (count($result) !== 1) {
+ return null; // no match
+ }
+
+ $newEntry = [
+ 'type' => $type,
+ 'value' => $mailAddr
+ ];
+
+ $match = $result[0];
+ $email = $match['EMAIL'] ?? [];
+ if (!empty($email) && !is_array($email[0])) {
+ $email = [$email];
+ }
+ $email[] = $newEntry;
+ $match['EMAIL'] = $email;
+
+ $updatedContact = $this->contactsManager->createOrUpdate($match, $match['addressbook-key']);
+ return $updatedContact;
+ }
+
+ /**
+ * Adds a new contact with the specified email to an addressbook
+ *
+ * @param string $uid
+ * @param string $mailAddr
+ * @param string $addressbook
+ * @return array|null
+ */
+ public function newContact(string $name, string $mailAddr, string $type = 'HOME', string $addressbook = null) {
+ if (!$this->contactsManager->isEnabled()) {
+ return null;
+ }
+
+ if (!isset($addressbook)) {
+ $addressbook = key($this->contactsManager->getUserAddressBooks());
+ }
+
+ $contact = [
+ 'FN' => $name,
+ 'EMAIL' => [
+ [
+ 'type' => $type,
+ 'value' => $mailAddr
+ ]
+ ]
+ ];
+ $createdContact = $this->contactsManager->createOrUpdate($contact, $addressbook);
+ return $createdContact;
+ }
+
+ private function doSearch($term, $fields): array {
+ $allowSystemUsers = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'no') === 'yes';
+
+ $result = $this->contactsManager->search($term, $fields);
+ $matches = [];
+ foreach ($result as $r) {
+ if (!$allowSystemUsers && isset($r['isLocalSystemBook']) && $r['isLocalSystemBook']) {
+ continue;
+ }
+ $id = $r['UID'];
+ $fn = $r['FN'];
+ $matches[] = [
+ 'id' => $id,
+ 'label' => $fn,
+ ];
+ }
+ return $matches;
+ }
+
+ /**
+ * Extracts all Contacts with the specified mail address
+ *
+ * @param string $mailAddr
+ * @return array
+ */
+ public function getContactsWithMail(string $mailAddr) {
+ return $this->doSearch($mailAddr, ['EMAIL']);
+ }
+
+ /**
+ * Extracts all Contacts with the specified name
+ *
+ * @param string $mailAddr
+ * @return array
+ */
+ public function getContactsWithName($name) {
+ return $this->doSearch($name, ['FN']);
+ }
}
diff --git a/src/components/RecipientBubble.vue b/src/components/RecipientBubble.vue
index 897f542a5..48bfe3afb 100644
--- a/src/components/RecipientBubble.vue
+++ b/src/components/RecipientBubble.vue
@@ -20,23 +20,92 @@
-->
<template>
- <UserBubble :display-name="label"
- :avatar-image="avatarUrlAbsolute"
- @click="onClick">
- <span class="user-bubble-email">{{ email }}</span>
- </UserBubble>
+ <Popover trigger="click" class="contact-popover">
+ <UserBubble slot="trigger"
+ :display-name="label"
+ :avatar-image="avatarUrlAbsolute"
+ @click="onClickOpenContactDialog">
+ <span class="user-bubble-email">{{ email }}</span>
+ </UserBubble>
+ <template>
+ <div class="contact-wrapper">
+ <div v-if="contactsWithEmail && contactsWithEmail.length > 0" class="contact-existing">
+ <span class="icon-details">
+ {{ t('mail', 'Contacts with this address') }}:
+ </span>
+ <span>
+ {{ contactsWithEmailComputed }}
+ </span>
+ </div>
+ <div v-if="selection === ContactSelectionStateEnum.select">
+ <a class="icon-reply" @click="onClickReply">
+ <span class="action-label">{{ t('mail', 'Reply') }}</span>
+ </a>
+ <a class="icon-user" @click="selection = ContactSelectionStateEnum.existing">
+ <span class="action-label">{{ t('mail', 'Add to Contact') }}</span>
+ </a>
+ <a class="icon-add" @click="selection = ContactSelectionStateEnum.new">
+ <span class="action-label">{{ t('mail', 'New Contact') }}</span>
+ </a>
+ </div>
+ <div v-else class="contact-input-wrapper">
+ <Multiselect
+ v-if="selection === ContactSelectionStateEnum.existing"
+ id="contact-selection"
+ ref="contact-selection-label"
+ v-model="selectedContact"
+ :options="selectableContacts"
+ :taggable="true"
+ label="label"
+ track-by="label"
+ :multiple="false"
+ :placeholder="t('name', 'Contact name …')"
+ :clear-on-select="false"
+ :show-no-options="false"
+ :preserve-search="true"
+ @search-change="onAutocomplete" />
+
+ <input v-else-if="selection === ContactSelectionStateEnum.new" v-model="newContactName">
+ </div>
+ <div v-if="selection !== ContactSelectionStateEnum.select">
+ <a class="icon-close" type="button" @click="selection = ContactSelectionStateEnum.select">
+ {{ t('mail', 'Go back') }}
+ </a>
+ <a
+ v-close-popover
+ :disabled="addButtonDisabled"
+ class="icon-checkmark"
+ type="button"
+ @click="onClickAddToContact">
+ {{ t('mail', 'Add') }}
+ </a>
+ </div>
+ </div>
+ </template>
+ </Popover>
</template>
<script>
import { generateUrl } from '@nextcloud/router'
import UserBubble from '@nextcloud/vue/dist/Components/UserBubble'
+import Popover from '@nextcloud/vue/dist/Components/Popover'
+import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
import { fetchAvatarUrlMemoized } from '../service/AvatarService'
+import { addToContact, findMatches, newContact, autoCompleteByName } from '../service/ContactIntegrationService'
+import uniqBy from 'lodash/fp/uniqBy'
+import debouncePromise from 'debounce-promise'
+
+const debouncedSearch = debouncePromise(autoCompleteByName, 500)
+
+const ContactSelectionStateEnum = Object.freeze({ new: 1, existing: 2, select: 3 })
export default {
name: 'RecipientBubble',
components: {
UserBubble,
+ Popover,
+ Multiselect,
},
props: {
email: {
@@ -51,6 +120,14 @@ export default {
data() {
return {
avatarUrl: undefined,
+ loadingContacts: false,
+ contactsWithEmail: [],
+ autoCompleteContacts: [],
+ selectedContact: '',
+ newContactName: '',
+ ContactSelectionStateEnum,
+ selection: ContactSelectionStateEnum.select,
+ isContactPopoverOpen: false,
}
},
computed: {
@@ -65,6 +142,21 @@ export default {
// Make it an absolute URL because the user bubble component doesn't work with relative URLs
return window.location.protocol + '//' + window.location.host + generateUrl(this.avatarUrl)
},
+ selectableContacts() {
+ return this.autoCompleteContacts
+ .map((contact) => ({ ...contact, label: contact.label }))
+ },
+ contactsWithEmailComputed() {
+ let additional = ''
+ if (this.contactsWithEmail && this.contactsWithEmail.length > 3) {
+ additional = ` + ${this.contactsWithEmail.length - 3}`
+ }
+ return this.contactsWithEmail.slice(0, 3).map(e => e.label).join(', ').concat(additional)
+ },
+ addButtonDisabled() {
+ return !((this.selection === ContactSelectionStateEnum.existing && this.selectedContact)
+ || (this.selection === ContactSelectionStateEnum.new && this.newContactName.trim() !== ''))
+ },
},
async mounted() {
try {
@@ -74,9 +166,10 @@ export default {
error,
})
}
+ this.newContactName = this.label
},
methods: {
- onClick() {
+ onClickReply() {
this.$router.push({
name: 'message',
params: {
@@ -88,6 +181,34 @@ export default {
},
})
},
+ onClickOpenContactDialog() {
+ if (this.contactsWithEmail.length === 0) { // TODO fix me
+ findMatches(this.email).then(res => {
+ if (res && res.length > 0) {
+ this.contactsWithEmail = res
+ }
+ })
+ }
+ },
+ onClickAddToContact() {
+ if (this.selection === ContactSelectionStateEnum.new) {
+ if (this.newContactName !== '') {
+ newContact(this.newContactName.trim(), this.email).then(res => console.debug('ContactIntegration', res))
+ }
+ } else if (this.selection === ContactSelectionStateEnum.existing) {
+ if (this.selectedContact) {
+ addToContact(this.selectedContact.id, this.email).then(res => console.debug('ContactIntegration', res))
+ }
+ }
+ },
+ onAutocomplete(term) {
+ if (term === undefined || term === '') {
+ return
+ }
+ debouncedSearch(term).then((results) => {
+ this.autoCompleteContacts = uniqBy('id')(this.autoCompleteContacts.concat(results))
+ })
+ },
},
}
</script>
@@ -99,4 +220,60 @@ export default {
.user-bubble-email {
margin: 10px;
}
+
+.contact-popover {
+ display: inline-block;
+}
+.contact-wrapper {
+ padding:10px;
+ min-width: 300px;
+
+ a {
+ opacity: 0.7;
+ }
+ a:hover {
+ opacity: 1;
+ }
+}
+.contact-input-wrapper {
+ margin-top: 10px;
+ margin-bottom: 10px;
+ input,
+ .multiselect {
+ width: 100%;
+ }
+}
+.icon-user,
+.icon-reply,
+.icon-checkmark,
+.icon-close,
+.icon-add {
+ height: 44px;
+ min-width: 44px;
+ margin: 0;
+ padding: 9px 18px 10px 32px;
+}
+@media only screen and (min-width: 600px) {
+ .icon-user,
+ .icon-reply,
+ .icon-checkmark,
+ .icon-close,
+ .icon-add {
+ background-position: 12px center;
+ }
+}
+.icon-add {
+ display: revert;
+ vertical-align: revert;
+}
+.contact-existing {
+ margin-bottom: 10px;
+ font-size: small;
+ .icon-details {
+ padding-left: 34px;
+ background-position: 10px center;
+ text-align: left;
+ }
+}
+
</style>
diff --git a/src/service/ContactIntegrationService.js b/src/service/ContactIntegrationService.js
new file mode 100644
index 000000000..fdd43f3a6
--- /dev/null
+++ b/src/service/ContactIntegrationService.js
@@ -0,0 +1,51 @@
+/*
+ * @copyright 2021 Kristian Lebold <kristian@lebold.info>
+ *
+ * @author 2021 Kristian Lebold <kristian@lebold.info>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * 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 <http://www.gnu.org/licenses/>.
+ */
+
+import Axios from '@nextcloud/axios'
+import { generateUrl } from '@nextcloud/router'
+
+export const findMatches = (mail) => {
+ const url = generateUrl('/apps/mail/api/contactIntegration/match/{mail}', {
+ mail,
+ })
+
+ return Axios.get(url).then((resp) => resp.data)
+}
+
+export const addToContact = (id, mailAddr) => {
+ const url = generateUrl('/apps/mail/api/contactIntegration/add')
+
+ return Axios.put(url, { uid: id, mail: mailAddr }).then((resp) => resp.data)
+}
+
+export const newContact = (name, mailAddr) => {
+ const url = generateUrl('/apps/mail/api/contactIntegration/new')
+
+ return Axios.put(url, { contactName: name, mail: mailAddr }).then((resp) => resp.data)
+}
+
+export const autoCompleteByName = (term) => {
+ const url = generateUrl('/apps/mail/api/contactIntegration/autoComplete/{term}', {
+ term,
+ })
+
+ return Axios.get(url).then((resp) => resp.data)
+}
diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml
index d2e94c52b..1cb8ae6f3 100644
--- a/tests/psalm-baseline.xml
+++ b/tests/psalm-baseline.xml
@@ -256,6 +256,11 @@
<code>$predictedValidationLabel</code>
</InvalidScalarArgument>
</file>
+ <file src="lib/Service/ContactsIntegration.php">
+ <UndefinedDocblockClass occurrences="1">
+ <code>$this-&gt;contactsManager-&gt;getUserAddressBooks()</code>
+ </UndefinedDocblockClass>
+ </file>
<file src="lib/Service/HtmlPurify/TransformURLScheme.php">
<NullArgument occurrences="3">
<code>null</code>