diff --git a/CHANGELOG b/CHANGELOG index 713f84bca..17f4ed2a6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,10 @@ CHANGELOG Roundcube Webmail =========================== +- Automatically collected recipients and trusted senders (#6904) + - Added configurable Collected Recipients addressbook source (#4971) + - Added configurable Trusted Senders addressbook source (#5046) + - Added 'contact_exists' hook - OAuth/XOauth support (#7425, #6933) - Cache refactoring (#6312) - Added special value 'email' to login_username_filter, it changes also logon input type (#7179) diff --git a/SQL/mssql.initial.sql b/SQL/mssql.initial.sql index 72857dc36..4515ca41d 100644 --- a/SQL/mssql.initial.sql +++ b/SQL/mssql.initial.sql @@ -40,6 +40,16 @@ CREATE TABLE [dbo].[cache_messages] ( ) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY] GO +CREATE TABLE [dbo].[collected_addresses] ( + [address_id] [int] IDENTITY (1, 1) NOT NULL , + [user_id] [int] NOT NULL , + [changed] [datetime] NOT NULL , + [name] [varchar] (255) COLLATE Latin1_General_CI_AI NOT NULL , + [email] [varchar] (255) COLLATE Latin1_General_CI_AI NOT NULL , + [type] [int] NOT NULL +) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY] +GO + CREATE TABLE [dbo].[contacts] ( [contact_id] [int] IDENTITY (1, 1) NOT NULL , [user_id] [int] NOT NULL , @@ -174,6 +184,13 @@ ALTER TABLE [dbo].[cache_messages] WITH NOCHECK ADD ) ON [PRIMARY] GO +ALTER TABLE [dbo].[collected_addresses] WITH NOCHECK ADD + CONSTRAINT [PK_collected_addresses_addres_id] PRIMARY KEY CLUSTERED + ( + [address_id] + ) ON [PRIMARY] +GO + ALTER TABLE [dbo].[contacts] WITH NOCHECK ADD CONSTRAINT [PK_contacts_contact_id] PRIMARY KEY CLUSTERED ( @@ -277,6 +294,15 @@ GO CREATE INDEX [IX_cache_messages_expires] ON [dbo].[cache_messages]([expires]) ON [PRIMARY] GO +ALTER TABLE [dbo].[collected_addresses] ADD + CONSTRAINT [DF_collected_addresses_user_id] DEFAULT (0) FOR [user_id], + CONSTRAINT [DF_collected_addresses_changed] DEFAULT (getdate()) FOR [changed], + CONSTRAINT [DF_collected_addresses_name] DEFAULT ('') FOR [name], +GO + +CREATE UNIQUE INDEX [IX_collected_addresses_user_id] ON [dbo].[collected_addresses]([user_id],[type],[email]) ON [PRIMARY] +GO + ALTER TABLE [dbo].[contacts] ADD CONSTRAINT [DF_contacts_user_id] DEFAULT (0) FOR [user_id], CONSTRAINT [DF_contacts_changed] DEFAULT (getdate()) FOR [changed], @@ -369,6 +395,11 @@ ALTER TABLE [dbo].[identities] ADD CONSTRAINT [FK_identities_user_id] ON DELETE CASCADE ON UPDATE CASCADE GO +ALTER TABLE [dbo].[collected_addresses] ADD CONSTRAINT [FK_collected_addresses_user_id] + FOREIGN KEY ([user_id]) REFERENCES [dbo].[users] ([user_id]) + ON DELETE CASCADE ON UPDATE CASCADE +GO + ALTER TABLE [dbo].[contacts] ADD CONSTRAINT [FK_contacts_user_id] FOREIGN KEY ([user_id]) REFERENCES [dbo].[users] ([user_id]) ON DELETE CASCADE ON UPDATE CASCADE @@ -422,6 +453,6 @@ CREATE TRIGGER [contact_delete_member] ON [dbo].[contacts] WHERE [contact_id] IN (SELECT [contact_id] FROM deleted) GO -INSERT INTO [dbo].[system] ([name], [value]) VALUES ('roundcube-version', '2020020101') +INSERT INTO [dbo].[system] ([name], [value]) VALUES ('roundcube-version', '2020091000') GO \ No newline at end of file diff --git a/SQL/mssql/2020091000.sql b/SQL/mssql/2020091000.sql new file mode 100644 index 000000000..2f79b5287 --- /dev/null +++ b/SQL/mssql/2020091000.sql @@ -0,0 +1,31 @@ +CREATE TABLE [dbo].[collected_addresses] ( + [address_id] [int] IDENTITY (1, 1) NOT NULL , + [user_id] [int] NOT NULL , + [changed] [datetime] NOT NULL , + [name] [varchar] (255) COLLATE Latin1_General_CI_AI NOT NULL , + [email] [varchar] (255) COLLATE Latin1_General_CI_AI NOT NULL , + [type] [int] NOT NULL +) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY] +GO + +ALTER TABLE [dbo].[collected_addresses] WITH NOCHECK ADD + CONSTRAINT [PK_collected_addresses_addres_id] PRIMARY KEY CLUSTERED + ( + [address_id] + ) ON [PRIMARY] +GO + +ALTER TABLE [dbo].[collected_addresses] ADD + CONSTRAINT [DF_collected_addresses_user_id] DEFAULT (0) FOR [user_id], + CONSTRAINT [DF_collected_addresses_changed] DEFAULT (getdate()) FOR [changed], + CONSTRAINT [DF_collected_addresses_name] DEFAULT ('') FOR [name], +GO + +CREATE UNIQUE INDEX [IX_collected_addresses_user_id] ON [dbo].[collected_addresses]([user_id],[type],[email]) ON [PRIMARY] +GO + +ALTER TABLE [dbo].[collected_addresses] ADD CONSTRAINT [FK_collected_addresses_user_id] + FOREIGN KEY ([user_id]) REFERENCES [dbo].[users] ([user_id]) + ON DELETE CASCADE ON UPDATE CASCADE +GO + diff --git a/SQL/mysql.initial.sql b/SQL/mysql.initial.sql index 8981e0c3b..2d308a1ba 100644 --- a/SQL/mysql.initial.sql +++ b/SQL/mysql.initial.sql @@ -102,6 +102,22 @@ CREATE TABLE `cache_messages` ( ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */; +-- Table structure for table `collected_addresses` + +CREATE TABLE `collected_addresses` ( + `address_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `changed` datetime NOT NULL DEFAULT '1000-01-01 00:00:00', + `name` varchar(255) NOT NULL DEFAULT '', + `email` varchar(255) NOT NULL, + `user_id` int(10) UNSIGNED NOT NULL, + `type` int(10) UNSIGNED NOT NULL, + PRIMARY KEY(`address_id`), + CONSTRAINT `user_id_fk_collected_addresses` FOREIGN KEY (`user_id`) + REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE, + UNIQUE INDEX `user_email_collected_addresses_index` (`user_id`, `type`, `email`) +) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */; + + -- Table structure for table `contacts` CREATE TABLE `contacts` ( @@ -121,6 +137,7 @@ CREATE TABLE `contacts` ( INDEX `user_contacts_index` (`user_id`,`del`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */; + -- Table structure for table `contactgroups` CREATE TABLE `contactgroups` ( @@ -135,6 +152,9 @@ CREATE TABLE `contactgroups` ( INDEX `contactgroups_user_index` (`user_id`,`del`) ) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */; + +-- Table structure for table `contactgroupmembers` + CREATE TABLE `contactgroupmembers` ( `contactgroup_id` int(10) UNSIGNED NOT NULL, `contact_id` int(10) UNSIGNED NOT NULL, @@ -223,4 +243,4 @@ CREATE TABLE `system` ( /*!40014 SET FOREIGN_KEY_CHECKS=1 */; -INSERT INTO `system` (`name`, `value`) VALUES ('roundcube-version', '2020020101'); +INSERT INTO `system` (`name`, `value`) VALUES ('roundcube-version', '2020091000'); diff --git a/SQL/mysql/2020091000.sql b/SQL/mysql/2020091000.sql new file mode 100644 index 000000000..cc91fe0f2 --- /dev/null +++ b/SQL/mysql/2020091000.sql @@ -0,0 +1,12 @@ +CREATE TABLE `collected_addresses` ( + `address_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `changed` datetime NOT NULL DEFAULT '1000-01-01 00:00:00', + `name` varchar(255) NOT NULL DEFAULT '', + `email` varchar(255) NOT NULL, + `user_id` int(10) UNSIGNED NOT NULL, + `type` int(10) UNSIGNED NOT NULL, + PRIMARY KEY(`address_id`), + CONSTRAINT `user_id_fk_collected_addresses` FOREIGN KEY (`user_id`) + REFERENCES `users`(`user_id`) ON DELETE CASCADE ON UPDATE CASCADE, + UNIQUE INDEX `user_email_collected_addresses_index` (`user_id`, `type`, `email`) +) /*!40000 ENGINE=INNODB */ /*!40101 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */; diff --git a/SQL/oracle.initial.sql b/SQL/oracle.initial.sql index 711e71904..f63b10ff5 100644 --- a/SQL/oracle.initial.sql +++ b/SQL/oracle.initial.sql @@ -63,6 +63,28 @@ BEGIN END; / +CREATE TABLE "collected_addresses" ( + "address_id" integer PRIMARY KEY, + "user_id" integer NOT NULL + REFERENCES "users" ("user_id") ON DELETE CASCADE, + "changed" timestamp with time zone DEFAULT current_timestamp NOT NULL, + "name" varchar(255) DEFAULT NULL, + "email" varchar(255) DEFAULT NULL, + "type" integer NOT NULL +); + +CREATE UNIQUE INDEX "collected_addresses_user_id_idx" ON "collected_addresses" ("user_id", "type", "email"); + +CREATE SEQUENCE "collected_addresses_seq" + START WITH 1 INCREMENT BY 1 NOMAXVALUE; + +CREATE TRIGGER "collected_addresses_seq_trig" +BEFORE INSERT ON "collected_addresses" FOR EACH ROW +BEGIN + :NEW."address_id" := "collected_addresses_seq".nextval; +END; +/ + CREATE TABLE "contacts" ( "contact_id" integer PRIMARY KEY, "user_id" integer NOT NULL @@ -238,4 +260,4 @@ CREATE TABLE "system" ( "value" long ); -INSERT INTO "system" ("name", "value") VALUES ('roundcube-version', '2020020101'); +INSERT INTO "system" ("name", "value") VALUES ('roundcube-version', '2020091000'); diff --git a/SQL/oracle/2020091000.sql b/SQL/oracle/2020091000.sql new file mode 100644 index 000000000..0e598cda6 --- /dev/null +++ b/SQL/oracle/2020091000.sql @@ -0,0 +1,21 @@ +CREATE TABLE "collected_addresses" ( + "address_id" integer PRIMARY KEY, + "user_id" integer NOT NULL + REFERENCES "users" ("user_id") ON DELETE CASCADE, + "changed" timestamp with time zone DEFAULT current_timestamp NOT NULL, + "name" varchar(255) DEFAULT NULL, + "email" varchar(255) DEFAULT NULL, + "type" integer NOT NULL +); + +CREATE UNIQUE INDEX "collected_addresses_user_id_idx" ON "collected_addresses" ("user_id", "type", "email"); + +CREATE SEQUENCE "collected_addresses_seq" + START WITH 1 INCREMENT BY 1 NOMAXVALUE; + +CREATE TRIGGER "collected_addresses_seq_trig" +BEFORE INSERT ON "collected_addresses" FOR EACH ROW +BEGIN + :NEW."address_id" := "collected_addresses_seq".nextval; +END; +/ diff --git a/SQL/postgres.initial.sql b/SQL/postgres.initial.sql index 02be65d98..ab39ad713 100644 --- a/SQL/postgres.initial.sql +++ b/SQL/postgres.initial.sql @@ -81,6 +81,35 @@ CREATE TABLE identities ( CREATE INDEX identities_user_id_idx ON identities (user_id, del); CREATE INDEX identities_email_idx ON identities (email, del); +-- +-- Sequence "collected_addresses_seq" +-- Name: collected_addresses_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE collected_addresses_seq + START WITH 1 + INCREMENT BY 1 + NO MAXVALUE + NO MINVALUE + CACHE 1; + +-- +-- Table "collected_addresses" +-- Name: collected_addresses; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE collected_addresses ( + address_id integer DEFAULT nextval('collected_addresses_seq'::text) PRIMARY KEY, + user_id integer NOT NULL + REFERENCES users (user_id) ON DELETE CASCADE ON UPDATE CASCADE, + changed timestamp with time zone DEFAULT now() NOT NULL, + name varchar(255) DEFAULT '' NOT NULL, + email varchar(255) NOT NULL, + "type" integer NOT NULL +); + +CREATE UNIQUE INDEX collected_addresses_user_id_idx ON collected_addresses (user_id, "type", email); + -- -- Sequence "contacts_seq" @@ -314,4 +343,4 @@ CREATE TABLE "system" ( value text ); -INSERT INTO "system" (name, value) VALUES ('roundcube-version', '2020020101'); +INSERT INTO "system" (name, value) VALUES ('roundcube-version', '2020091000'); diff --git a/SQL/postgres/2020091000.sql b/SQL/postgres/2020091000.sql new file mode 100644 index 000000000..bab9f4eed --- /dev/null +++ b/SQL/postgres/2020091000.sql @@ -0,0 +1,11 @@ +CREATE TABLE collected_addresses ( + address_id integer DEFAULT nextval('collected_addresses_seq'::text) PRIMARY KEY, + user_id integer NOT NULL + REFERENCES users (user_id) ON DELETE CASCADE ON UPDATE CASCADE, + changed timestamp with time zone DEFAULT now() NOT NULL, + name varchar(255) DEFAULT '' NOT NULL, + email varchar(255) NOT NULL, + "type" integer NOT NULL +); + +CREATE UNIQUE INDEX collected_addresses_user_id_idx ON collected_addresses (user_id, "type", email); diff --git a/SQL/sqlite.initial.sql b/SQL/sqlite.initial.sql index 42ab425f1..f9bb845ae 100644 --- a/SQL/sqlite.initial.sql +++ b/SQL/sqlite.initial.sql @@ -40,6 +40,21 @@ CREATE TABLE contactgroupmembers ( CREATE INDEX ix_contactgroupmembers_contact_id ON contactgroupmembers (contact_id); +-- +-- Table structure for table collected_addresses +-- + +CREATE TABLE collected_addresses ( + address_id integer NOT NULL PRIMARY KEY, + user_id integer NOT NULL, + changed datetime NOT NULL default '0000-00-00 00:00:00', + name varchar(255) NOT NULL default '', + email varchar(255) NOT NULL, + "type" integer NOT NULL +); + +CREATE UNIQUE INDEX ix_collected_addresses_user_id ON collected_addresses(user_id, "type", email); + -- -- Table structure for table identities -- @@ -215,4 +230,4 @@ CREATE TABLE system ( value text NOT NULL ); -INSERT INTO system (name, value) VALUES ('roundcube-version', '2020020101'); +INSERT INTO system (name, value) VALUES ('roundcube-version', '2020091000'); diff --git a/SQL/sqlite/2020091000.sql b/SQL/sqlite/2020091000.sql new file mode 100644 index 000000000..2ea309629 --- /dev/null +++ b/SQL/sqlite/2020091000.sql @@ -0,0 +1,10 @@ +CREATE TABLE collected_addresses ( + address_id integer NOT NULL PRIMARY KEY, + user_id integer NOT NULL, + changed datetime NOT NULL default '0000-00-00 00:00:00', + name varchar(255) NOT NULL default '', + email varchar(255) NOT NULL, + "type" integer NOT NULL +); + +CREATE UNIQUE INDEX ix_collected_addresses_user_id ON collected_addresses(user_id, "type", email); diff --git a/config/defaults.inc.php b/config/defaults.inc.php index 66bae3fc2..9f4104478 100644 --- a/config/defaults.inc.php +++ b/config/defaults.inc.php @@ -1173,6 +1173,17 @@ $config['contactlist_fields'] = array('name', 'firstname', 'surname', 'email'); // See program/steps/addressbook/func.inc for a list $config['contact_search_name'] = '{name} <{email}>'; +// The addressbook source to store automatically collected recipients in. +// Default: true (the build-in "Collected recipients" addressbook, source id = '1') +// Note: It can be set to any writeable addressbook, e.g. 'sql' +$config['collected_recipients'] = true; + +// The addressbook source to store trusted senders in. +// Default: true (the build-in "Trusted senders" addressbook, source id = '2') +// Note: It can be set to any writeable addressbook, e.g. 'sql' +$config['collected_senders'] = true; + + // ---------------------------------- // USER PREFERENCES // ---------------------------------- diff --git a/plugins/vcard_attachments/vcard_attachments.php b/plugins/vcard_attachments/vcard_attachments.php index 458c24272..152e2bb8b 100644 --- a/plugins/vcard_attachments/vcard_attachments.php +++ b/plugins/vcard_attachments/vcard_attachments.php @@ -281,10 +281,9 @@ class vcard_attachments extends rcube_plugin } $rcmail = rcmail::get_instance(); - $abook = $rcmail->config->get('default_addressbook'); // Get configured addressbook - $CONTACTS = $rcmail->get_address_book($abook, true); + $CONTACTS = $rcmail->get_address_book(rcube_addressbook::TYPE_DEFAULT, true); // Get first writeable addressbook if the configured doesn't exist // This can happen when user deleted the addressbook (e.g. Kolab folder) diff --git a/program/include/rcmail.php b/program/include/rcmail.php index b983f3ac8..89090dbbf 100644 --- a/program/include/rcmail.php +++ b/program/include/rcmail.php @@ -208,7 +208,9 @@ class rcmail extends rcube /** * Return instance of the internal address book class * - * @param string $id Address book identifier (-1 for default addressbook) + * @param string $id Address book identifier. It accepts also special values: + * - rcube_addressbook::TYPE_CONTACT (or 'sql') for the SQL addressbook + * - rcube_addressbook::TYPE_DEFAULT for the default addressbook * @param boolean $writeable True if the address book needs to be writeable * * @return rcube_contacts Address book object @@ -221,13 +223,15 @@ class rcmail extends rcube // 'sql' is the alias for '0' used by autocomplete if ($id == 'sql') { - $id = '0'; + $id = rcube_addressbook::TYPE_CONTACT; } - else if ($id == -1) { + else if ($id == rcube_addressbook::TYPE_DEFAULT || $id == -1) { // -1 for BC $id = $this->config->get('default_addressbook'); $default = true; } + $id = (string) $id; + // use existing instance if (isset($this->address_books[$id]) && ($this->address_books[$id] instanceof rcube_addressbook)) { $contacts = $this->address_books[$id]; @@ -236,9 +240,12 @@ class rcmail extends rcube $domain = $this->config->mail_domain($_SESSION['storage_host']); $contacts = new rcube_ldap($ldap_config[$id], $this->config->get('ldap_debug'), $domain); } - else if ($id === '0') { + else if ($id === (string) rcube_addressbook::TYPE_CONTACT) { $contacts = new rcube_contacts($this->db, $this->get_user_id()); } + else if ($id === (string) rcube_addressbook::TYPE_RECIPIENT || $id === (string) rcube_addressbook::TYPE_TRUSTED_SENDER) { + $contacts = new rcube_addresses($this->db, $this->get_user_id(), (int) $id); + } else { $plugin = $this->plugins->exec_hook('addressbook_get', array('id' => $id, 'writeable' => $writeable)); @@ -325,22 +332,16 @@ class rcmail extends rcube { $abook_type = strtolower((string) $this->config->get('address_book_type', 'sql')); $ldap_config = (array) $this->config->get('ldap_public'); - $autocomplete = (array) $this->config->get('autocomplete_addressbooks'); $list = array(); // SQL-based (built-in) address book if ($abook_type === 'sql') { - if (!isset($this->address_books['0'])) { - $this->address_books['0'] = new rcube_contacts($this->db, $this->get_user_id()); - } - - $list['0'] = array( - 'id' => '0', + $list[rcube_addressbook::TYPE_CONTACT] = array( + 'id' => (string) rcube_addressbook::TYPE_CONTACT, 'name' => $this->gettext('personaladrbook'), - 'groups' => $this->address_books['0']->groups, - 'readonly' => $this->address_books['0']->readonly, - 'undelete' => $this->address_books['0']->undelete && $this->config->get('undo_timeout'), - 'autocomplete' => in_array_nocase('sql', $autocomplete), + 'groups' => true, + 'readonly' => false, + 'undelete' => $this->config->get('undo_timeout') > 0, ); } @@ -358,20 +359,40 @@ class rcmail extends rcube 'groups' => !empty($prop['groups']) || !empty($prop['group_filters']), 'readonly' => !$prop['writable'], 'hidden' => $prop['hidden'], - 'autocomplete' => in_array($id, $autocomplete) ); } } + $collected_recipients = $this->config->get('collected_recipients'); + $collected_senders = $this->config->get('collected_senders'); + + if ($collected_recipients === (string) rcube_addressbook::TYPE_RECIPIENT) { + $list[rcube_addressbook::TYPE_RECIPIENT] = array( + 'id' => (string) rcube_addressbook::TYPE_RECIPIENT, + 'name' => $this->gettext('collectedrecipients'), + 'groups' => false, + 'readonly' => true, + 'undelete' => false, + 'deletable' => true, + ); + } + + if ($collected_senders === (string) rcube_addressbook::TYPE_TRUSTED_SENDER) { + $list[rcube_addressbook::TYPE_TRUSTED_SENDER] = array( + 'id' => (string) rcube_addressbook::TYPE_TRUSTED_SENDER, + 'name' => $this->gettext('trustedsenders'), + 'groups' => false, + 'readonly' => true, + 'undelete' => false, + 'deletable' => true, + ); + } + // Plugins can also add address books, or re-order the list $plugin = $this->plugins->exec_hook('addressbooks_list', array('sources' => $list)); $list = $plugin['sources']; foreach ($list as $idx => $item) { - // register source for shutdown function - if (!is_object($this->address_books[$item['id']])) { - $this->address_books[$item['id']] = $item; - } // remove from list if not writeable as requested if ($writeable && $item['readonly']) { unset($list[$idx]); @@ -1169,6 +1190,108 @@ class rcmail extends rcube } } + /** + * Insert a contact to specified addressbook. + * + * @param array $contact Contact data + * @param rcube_addressbook $source The addressbook object + * @param string $error Filled with an error message/label on error + * + * @return int|bool Contact ID on success, False otherwise + */ + public function contact_create($contact, $source, &$error = null) + { + $contact['email'] = rcube_utils::idn_to_utf8($contact['email']); + + $contact = $this->plugins->exec_hook('contact_displayname', $contact); + + if (empty($contact['name'])) { + $contact['name'] = rcube_addressbook::compose_display_name($contact); + } + + // validate the contact + if (!$source->validate($contact, true)) { + $error = $source->get_error(); + return false; + } + + $plugin = $this->plugins->exec_hook('contact_create', array( + 'record' => $contact, + 'source' => $this->get_address_book_id($source), + )); + + $contact = $plugin['record']; + + if (!empty($plugin['abort'])) { + $error = $plugin['message']; + return $plugin['result']; + } + + return $source->insert($contact); + } + + /** + * Find an email address in user addressbook(s) + * + * @param string $email Email address + * @param int $type Addressbook type (see rcube_addressbook::TYPE_* consts) + * + * @return bool True if the address exists in specified addressbook(s), False otherwise + */ + public function contact_exists($email, $type) + { + if (empty($email) || !is_string($email) || !strpos($email, '@')) { + return false; + } + + // TODO: Consider using all writeable addressbooks by default + // TODO: Support TYPE_DEFAULT, TYPE_WRITEABLE, TYPE_READONLY filter + + if ($default = $this->get_address_book(rcube_addressbook::TYPE_DEFAULT, true)) { + $sources = array($this->get_address_book_id($default)); + } + + if ($type & rcube_addressbook::TYPE_RECIPIENT) { + $collected_recipients = $this->config->get('collected_recipients'); + if (strlen($collected_recipients) && !in_array($collected_recipients, $sources)) { + array_unshift($sources, $collected_recipients); + } + } + + if ($type & rcube_addressbook::TYPE_TRUSTED_SENDER) { + $collected_senders = $this->config->get('collected_senders'); + if (strlen($collected_senders) && !in_array($collected_senders, $sources)) { + array_unshift($sources, $collected_senders); + } + } + + $plugin = $this->plugins->exec_hook('contact_exists', array( + 'email' => $email, + 'type' => $type, + 'sources' => $sources, + )); + + if (!empty($plugin['abort'])) { + return $plugin['result']; + } + + foreach ($plugin['sources'] as $source) { + $contacts = $this->get_address_book($source); + + if (!$contacts) { + continue; + } + + $result = $contacts->search('email', $email, rcube_addressbook::SEARCH_STRICT, false); + + if ($result->count) { + return true; + } + } + + return false; + } + /** * Returns RFC2822 formatted current date in user's timezone * diff --git a/program/include/rcmail_sendmail.php b/program/include/rcmail_sendmail.php index 6669737c1..2f5801c3a 100644 --- a/program/include/rcmail_sendmail.php +++ b/program/include/rcmail_sendmail.php @@ -422,6 +422,9 @@ class rcmail_sendmail $this->rcmail->user->save_prefs(array('last_message_time' => time())); } + // Collect recipients' addresses + $this->collect_recipients($message); + // set replied/forwarded flag if ($this->data['reply_uid']) { foreach (rcmail::get_uids($this->data['reply_uid'], $this->data['mailbox']) as $mbox => $uids) { @@ -1573,4 +1576,54 @@ class rcmail_sendmail // default identity is always first on the list return $identities[$selected !== null ? $selected : 0]; } + + /** + * Collect message recipients' addresses + * + * @param Mail_Mime $message The email message + */ + public static function collect_recipients($message) + { + $rcmail = rcube::get_instance(); + + // Find the addressbook source + $collected_recipients = $rcmail->config->get('collected_recipients'); + + if (!strlen($collected_recipients)) { + return; + } + + $source = $rcmail->get_address_book($collected_recipients); + + if (!$source) { + return; + } + + $headers = $message->headers(); + + // extract recipients + $recipients = (array) $headers['To']; + + if (strlen($headers['Cc'])) { + $recipients[] = $headers['Cc']; + } + + if (strlen($headers['Bcc'])) { + $recipients[] = $headers['Bcc']; + } + + $addresses = rcube_mime::decode_address_list($recipients); + $type = rcube_addressbook::TYPE_DEFAULT | rcube_addressbook::TYPE_RECIPIENT; + + foreach ($addresses as $address) { + $contact = array( + 'name' => $address['name'], + 'email' => $address['mailto'], + ); + + if (!$rcmail->contact_exists($contact['email'], $type)) { + $rcmail->contact_create($contact, $source); + } + } + } } diff --git a/program/js/app.js b/program/js/app.js index a7a73f892..8c395e9d4 100644 --- a/program/js/app.js +++ b/program/js/app.js @@ -6198,7 +6198,11 @@ function rcube_webmail() if (this.preview_timer) clearTimeout(this.preview_timer); - var id, targets, groupcount = 0, writable = false, copy_writable = false, + var id, targets, + groupcount = 0, + writable = false, + deletable = false, + copy_writable = false, selected = list.get_selection().length, source = this.env.source ? this.env.address_sources[this.env.source] : null; @@ -6222,16 +6226,20 @@ function rcube_webmail() } $.each(list.get_selection(), function(i, v) { - var sid, contact = list.data[v]; + var book, sid, contact = list.data[v]; if (!source) { sid = String(v).replace(/^[^-]+-/, ''); - if (sid && ref.env.address_sources[sid]) { - writable = writable || (!ref.env.address_sources[sid].readonly && !contact.readonly); + book = sid ? ref.env.address_sources[sid] : null; + + if (book) { + writable = writable || (!book.readonly && !contact.readonly); + deletable = deletable || book.deletable === true; ref.env.selection_sources.push(sid); } } else { writable = writable || (!source.readonly && !contact.readonly); + deletable = deletable || source.deletable === true; } if (contact._type != 'group') @@ -6254,7 +6262,7 @@ function rcube_webmail() this.enable_command('print', 'qrcode', selected == 1); this.enable_command('export-selected', selected > 0); this.enable_command('edit', id && writable); - this.enable_command('delete', 'move', writable); + this.enable_command('delete', 'move', writable || deletable); this.enable_command('copy', copy_writable); return false; @@ -6538,6 +6546,9 @@ function rcube_webmail() if (!this.env.address_sources[dest] || this.env.address_sources[dest].readonly) return; + if (!cid) + cid = this.contact_list.get_selection(); + // search result may contain contacts from many sources, but if there is only one... if (source == '' && this.env.selection_sources.length == 1) source = this.env.selection_sources[0]; diff --git a/program/lib/Roundcube/rcube_addressbook.php b/program/lib/Roundcube/rcube_addressbook.php index 2b355d127..9ce5858ea 100644 --- a/program/lib/Roundcube/rcube_addressbook.php +++ b/program/lib/Roundcube/rcube_addressbook.php @@ -38,6 +38,14 @@ abstract class rcube_addressbook const SEARCH_PREFIX = 2; const SEARCH_GROUPS = 4; + // contact types, note: some of these are used as addressbook source identifiers + const TYPE_CONTACT = 0; + const TYPE_RECIPIENT = 1; + const TYPE_TRUSTED_SENDER = 2; + const TYPE_DEFAULT = 4; + const TYPE_WRITEABLE = 8; + const TYPE_READONLY = 16; + // public properties (mandatory) public $primary_key; public $groups = false; diff --git a/program/lib/Roundcube/rcube_addresses.php b/program/lib/Roundcube/rcube_addresses.php new file mode 100644 index 000000000..3b28f781f --- /dev/null +++ b/program/lib/Roundcube/rcube_addresses.php @@ -0,0 +1,405 @@ + | + +-----------------------------------------------------------------------+ +*/ + +/** + * Collected addresses database + * + * @package Framework + * @subpackage Addressbook + */ +class rcube_addresses extends rcube_contacts +{ + protected $db_name = 'collected_addresses'; + protected $type = 0; + protected $table_cols = array('name', 'email'); + protected $fulltext_cols = array('name'); + + // public properties + public $primary_key = 'address_id'; + public $readonly = true; + public $groups = false; + public $undelete = false; + public $deletable = true; + public $coltypes = array('name', 'email'); + public $date_cols = array(); + + + /** + * Object constructor + * + * @param object $dbconn Instance of the rcube_db class + * @param integer $user User-ID + * @param int $type Type of the address (1 - recipient, 2 - trusted sender) + */ + public function __construct($dbconn, $user, $type) + { + $this->db = $dbconn; + $this->user_id = $user; + $this->type = $type; + $this->ready = $this->db && !$this->db->is_error(); + } + + /** + * Returns addressbook name + */ + public function get_name() + { + if ($this->type == self::TYPE_RECIPIENT) { + return rcube::get_instance()->gettext('collectedrecipients'); + } + + if ($this->type == self::TYPE_TRUSTED_SENDER) { + return rcube::get_instance()->gettext('trustedsenders'); + } + + return ''; + } + + /** + * List the current set of contact records + * + * @param array List of cols to show, Null means all + * @param int Only return this number of records, use negative values for tail + * @param boolean True to skip the count query (select only) + * + * @return array Indexed list of contact records, each a hash array + */ + public function list_records($cols = null, $subset = 0, $nocount = false) + { + if ($nocount || $this->list_page <= 1) { + // create dummy result, we don't need a count now + $this->result = new rcube_result_set(); + } else { + // count all records + $this->result = $this->count(); + } + + $start_row = $subset < 0 ? $this->result->first + $this->page_size + $subset : $this->result->first; + $length = $subset != 0 ? abs($subset) : $this->page_size; + + $sql_result = $this->db->limitquery( + "SELECT * FROM " . $this->db->table_name($this->db_name, true) + . " WHERE `user_id` = ? AND `type` = ?" + . ($this->filter ? " AND ".$this->filter : "") + . " ORDER BY `name` " . $this->sort_order . ", `email` " . $this->sort_order, + $start_row, + $length, + $this->user_id, + $this->type, + ); + + while ($sql_result && ($sql_arr = $this->db->fetch_assoc($sql_result))) { + $sql_arr['ID'] = $sql_arr[$this->primary_key]; + $this->result->add($sql_arr); + } + + $cnt = count($this->result->records); + + // update counter + if ($nocount) { + $this->result->count = $cnt; + } + else if ($this->list_page <= 1) { + if ($cnt < $this->page_size && $subset == 0) { + $this->result->count = $cnt; + } + else if (isset($this->cache['count'])) { + $this->result->count = $this->cache['count']; + } + else { + $this->result->count = $this->_count(); + } + } + + return $this->result; + } + + /** + * Search contacts + * + * @param mixed $fields The field name or array of field names to search in + * @param mixed $value Search value (or array of values when $fields is array) + * @param int $mode Search mode. Sum of rcube_addressbook::SEARCH_* + * @param boolean $select True if results are requested, False if count only + * @param boolean $nocount True to skip the count query (select only) + * @param array $required List of fields that cannot be empty + * + * @return object rcube_result_set Contact records and 'count' value + */ + public function search($fields, $value, $mode = 0, $select = true, $nocount = false, $required = array()) + { + if (!is_array($required) && !empty($required)) { + $required = array($required); + } + + $where = $post_search = array(); + $mode = intval($mode); + + // direct ID search + if ($fields == 'ID' || $fields == $this->primary_key) { + $ids = !is_array($value) ? explode(self::SEPARATOR, $value) : $value; + $ids = $this->db->array2list($ids, 'integer'); + $where[] = $this->primary_key . ' IN (' . $ids . ')'; + } + else if (is_array($value)) { + foreach ((array) $fields as $idx => $col) { + $val = $value[$idx]; + + if (!strlen($val)) { + continue; + } + + // table column + if ($col == 'email' && ($mode & rcube_addressbook::SEARCH_STRICT)) { + $where[] = $this->db->ilike($col, $val); + } + else if (in_array($col, $this->table_cols)) { + $where[] = $this->fulltext_sql_where($val, $mode, $col); + } + else { + $where[] = '1 = 0'; // unsupported column + } + } + } + else { + // fulltext search in all fields + if ($fields == '*') { + $fields = array('name', 'email'); + } + + // require each word in to be present in one of the fields + $words = ($mode & rcube_addressbook::SEARCH_STRICT) ? array($value) : rcube_utils::tokenize_string($value, 1); + foreach ($words as $word) { + $groups = array(); + foreach ((array) $fields as $idx => $col) { + if ($col == 'email' && ($mode & rcube_addressbook::SEARCH_STRICT)) { + $groups[] = $this->db->ilike($col, $word); + } + else if (in_array($col, $this->table_cols)) { + $groups[] = $this->fulltext_sql_where($word, $mode, $col); + } + } + $where[] = '(' . implode(' OR ', $groups) . ')'; + } + } + + foreach (array_intersect($required, $this->table_cols) as $col) { + $where[] = $this->db->quote_identifier($col) . ' <> ' . $this->db->quote(''); + } + + if (!empty($where)) { + // use AND operator for advanced searches + $where = implode(' AND ', $where); + + $this->set_search_set($where); + + if ($select) { + $this->list_records(null, 0, $nocount); + } + else { + $this->result = $this->count(); + } + } + + return $this->result; + } + + /** + * Count number of available contacts in database + * + * @return int Contacts count + */ + protected function _count() + { + // count contacts for this user + $sql_result = $this->db->query( + "SELECT COUNT(`address_id`) AS cnt" + . " FROM " . $this->db->table_name($this->db_name, true) + . " WHERE `user_id` = ? AND `type` = ?" + . ($this->filter ? " AND (" . $this->filter . ")" : ""), + $this->user_id, + $this->type + ); + + $sql_arr = $this->db->fetch_assoc($sql_result); + + $this->cache['count'] = (int) $sql_arr['cnt']; + + return $this->cache['count']; + } + + /** + * Get a specific contact record + * + * @param mixed $id Record identifier(s) + * @param bool $assoc Enables returning associative array + * + * @return rcube_result_set|array Result object with all record fields + */ + function get_record($id, $assoc = false) + { + // return cached result + if ($this->result && ($first = $this->result->first()) && $first[$this->primary_key] == $id) { + return $assoc ? $first : $this->result; + } + + $this->db->query( + "SELECT * FROM " . $this->db->table_name($this->db_name, true) + . " WHERE `address_id` = ? AND `user_id` = ?", + $id, + $this->user_id + ); + + $this->result = null; + + if ($record = $this->db->fetch_assoc()) { + $record['ID'] = $record['address_id']; + $this->result = new rcube_result_set(1); + $this->result->add($record); + } + + return $assoc && !empty($record) ? $record : $this->result; + } + + /** + * Check the given data before saving. + * If input not valid, the message to display can be fetched using get_error() + * + * @param array &$save_data Associative array with data to save + * @param boolean $autofix Try to fix/complete record automatically + * + * @return boolean True if input is valid, False if not. + */ + public function validate(&$save_data, $autofix = false) + { + $email = array_filter($this->get_col_values('email', $save_data, true)); + + // require email + if (empty($email) || count($email) > 1) { + $this->set_error(self::ERROR_VALIDATE, 'noemailwarning'); + return false; + } + + $email = $email[0]; + + // check validity of the email address + if (!rcube_utils::check_email(rcube_utils::idn_to_ascii($email))) { + $error = $rcube->gettext(array('name' => 'emailformaterror', 'vars' => array('email' => $email))); + $this->set_error(self::ERROR_VALIDATE, $error); + return false; + } + + return true; + } + + /** + * Create a new contact record + * + * @param array $save_data Associative array with save data + * @param bool $check Enables validity checks + * + * @return integer|bool The created record ID on success, False on error + */ + function insert($save_data, $check = false) + { + if (!is_array($save_data)) { + return false; + } + + if ($check && ($existing = $this->search('email', $save_data['email'], false, false))) { + if ($existing->count) { + return false; + } + } + + $this->cache = null; + + $this->db->query( + "INSERT INTO " . $this->db->table_name($this->db_name, true) + . " (`user_id`, `changed`, `type`, `name`, `email`)" + . " VALUES (?, " . $this->db->now() . ", ?, ?, ?)", + $this->user_id, + $this->type, + $save_data['name'], + $save_data['email'] + ); + + return $this->db->insert_id($this->db_name); + } + + /** + * Update a specific contact record + * + * @param mixed $id Record identifier + * @param array $save_cols Associative array with save data + * + * @return boolean True on success, False on error + */ + function update($id, $save_cols) + { + return false; + } + + /** + * Delete one or more contact records + * + * @param array $ids Record identifiers + * @param boolean $force Remove record(s) irreversible (unsupported) + * + * @return int Number of removed records + */ + function delete($ids, $force = true) + { + if (!is_array($ids)) { + $ids = explode(self::SEPARATOR, $ids); + } + + $ids = $this->db->array2list($ids, 'integer'); + + // flag record as deleted (always) + $this->db->query( + "DELETE FROM " . $this->db->table_name($this->db_name, true) + . " WHERE `user_id` = ? AND `type` = ? AND `address_id` IN ($ids)", + $this->user_id, $this->type + ); + + $this->cache = null; + + return $this->db->affected_rows(); + } + + /** + * Remove all records from the database + * + * @param bool $with_groups Remove also groups + * + * @return int Number of removed records + */ + function delete_all($with_groups = false) + { + $this->db->query("DELETE FROM " . $this->db->table_name($this->db_name, true) + . " WHERE `user_id` = ? AND `type` = ?", + $this->user_id, $this->type + ); + + $this->cache = null; + + return $this->db->affected_rows(); + } +} diff --git a/program/lib/Roundcube/rcube_config.php b/program/lib/Roundcube/rcube_config.php index 2d164a308..4319d92fc 100644 --- a/program/lib/Roundcube/rcube_config.php +++ b/program/lib/Roundcube/rcube_config.php @@ -413,6 +413,18 @@ class rcube_config $result = $this->prop['supported_layouts'][0]; } } + else if ($name == 'collected_senders') { + if (is_bool($result)) { + $result = $result ? rcube_addressbook::TYPE_TRUSTED_SENDER : ''; + } + $result = (string) $result; + } + else if ($name == 'collected_recipients') { + if (is_bool($result)) { + $result = $result ? rcube_addressbook::TYPE_RECIPIENT : ''; + } + $result = (string) $result; + } $plugin = $rcube->plugins->exec_hook('config_get', array( 'name' => $name, 'default' => $def, 'result' => $result)); diff --git a/program/lib/Roundcube/rcube_contacts.php b/program/lib/Roundcube/rcube_contacts.php index bfc077084..8aed25503 100644 --- a/program/lib/Roundcube/rcube_contacts.php +++ b/program/lib/Roundcube/rcube_contacts.php @@ -36,13 +36,13 @@ class rcube_contacts extends rcube_addressbook * * @var rcube_db */ - private $db = null; - private $user_id = 0; - private $filter = null; - private $result = null; - private $cache; - private $table_cols = array('name', 'email', 'firstname', 'surname'); - private $fulltext_cols = array('name', 'firstname', 'surname', 'middlename', 'nickname', + protected $db = null; + protected $user_id = 0; + protected $filter = null; + protected $result = null; + protected $cache; + protected $table_cols = array('name', 'email', 'firstname', 'surname'); + protected $fulltext_cols = array('name', 'firstname', 'surname', 'middlename', 'nickname', 'jobtitle', 'organization', 'department', 'maidenname', 'email', 'phone', 'address', 'street', 'locality', 'zipcode', 'region', 'country', 'website', 'im', 'notes'); @@ -448,11 +448,10 @@ class rcube_contacts extends rcube_addressbook /** * Helper method to compose SQL where statements for fulltext searching */ - private function fulltext_sql_where($value, $mode, $col = 'words', $bool = 'AND') + protected function fulltext_sql_where($value, $mode, $col = 'words', $bool = 'AND') { - $WS = ' '; - $AS = $col == 'words' ? $WS : self::SEPARATOR; - $words = $col == 'words' ? rcube_utils::normalize_string($value, true) : array($value); + $AS = $col == 'words' ? ' ' : self::SEPARATOR; + $words = $col == 'words' ? rcube_utils::normalize_string($value, true, 1) : array($value); $where = array(); foreach ($words as $word) { @@ -491,7 +490,7 @@ class rcube_contacts extends rcube_addressbook * * @return int Contacts count */ - private function _count() + protected function _count() { $join = null; @@ -794,6 +793,8 @@ class rcube_contacts extends rcube_addressbook * * @param array $ids Record identifiers * @param boolean $force Remove record(s) irreversible (unsupported) + * + * @return int Number of removed records */ function delete($ids, $force = true) { @@ -821,6 +822,8 @@ class rcube_contacts extends rcube_addressbook * Undelete one or more contact records * * @param array $ids Record identifiers + * + * @return int Number of undeleted contact records */ function undelete($ids) { diff --git a/program/localization/en_US/labels.inc b/program/localization/en_US/labels.inc index 87a6272b8..9b7f4b148 100644 --- a/program/localization/en_US/labels.inc +++ b/program/localization/en_US/labels.inc @@ -599,6 +599,11 @@ $labels['replysamefolder'] = 'Place replies in the folder of the message being r $labels['defaultabook'] = 'Default address book'; $labels['autocompletesingle'] = 'Skip alternative email addresses in autocompletion'; $labels['listnamedisplay'] = 'List contacts as'; +$labels['collectedaddresses'] = 'Collected addresses'; +$labels['collectedrecipients'] = 'Collected recipients'; +$labels['collectedrecipientsopt'] = 'Store outgoing email recipients in'; +$labels['collectedsendersopt'] = 'Store trusted senders in'; +$labels['trustedsenders'] = 'Trusted senders'; $labels['spellcheckbeforesend'] = 'Check spelling before sending a message'; $labels['spellcheckoptions'] = 'Spellcheck Options'; $labels['spellcheckignoresyms'] = 'Ignore words with symbols'; diff --git a/program/steps/addressbook/delete.inc b/program/steps/addressbook/delete.inc index 32e8ce8be..1c3abf401 100644 --- a/program/steps/addressbook/delete.inc +++ b/program/steps/addressbook/delete.inc @@ -32,7 +32,7 @@ $RCMAIL->session->remove('contact_undo'); foreach ($cids as $source => $cid) { $CONTACTS = rcmail_contact_source($source); - if ($CONTACTS->readonly) { + if ($CONTACTS->readonly && empty($CONTACTS->deletable)) { // more sources? do nothing, probably we have search results from // more than one source, some of these sources can be readonly if (count($cids) == 1) { diff --git a/program/steps/addressbook/edit.inc b/program/steps/addressbook/edit.inc index 78d3c2ad1..562538668 100644 --- a/program/steps/addressbook/edit.inc +++ b/program/steps/addressbook/edit.inc @@ -46,7 +46,7 @@ else { } if (!$CONTACTS || $CONTACTS->readonly) { - $CONTACTS = $RCMAIL->get_address_book(-1, true); + $CONTACTS = $RCMAIL->get_address_book(rcube_addressbook::TYPE_DEFAULT, true); $source = $RCMAIL->get_address_book_id($CONTACTS); } @@ -215,13 +215,16 @@ function get_form_tags($attrib) if (empty($EDIT_FORM)) { $hiddenfields = new html_hiddenfield(); - if ($RCMAIL->action == 'edit') + if ($RCMAIL->action == 'edit') { $hiddenfields->add(array('name' => '_source', 'value' => $SOURCE_ID)); + } + $hiddenfields->add(array('name' => '_gid', 'value' => $CONTACTS->group_id)); $hiddenfields->add(array('name' => '_search', 'value' => rcube_utils::get_input_value('_search', rcube_utils::INPUT_GPC))); - if (($result = $CONTACTS->get_result()) && ($record = $result->first())) + if (($result = $CONTACTS->get_result()) && ($record = $result->first())) { $hiddenfields->add(array('name' => '_cid', 'value' => $record['ID'])); + } $form_start = $RCMAIL->output->request_form(array( 'name' => "form", 'method' => "post", @@ -255,8 +258,9 @@ function rcmail_source_selector($attrib) $select = new html_select($attrib); - foreach ($sources_list as $source) + foreach ($sources_list as $source) { $select->add($source['name'], $source['id']); + } return $select->show($SOURCE_ID); } diff --git a/program/steps/addressbook/func.inc b/program/steps/addressbook/func.inc index 70c2192d1..b35ac0bda 100644 --- a/program/steps/addressbook/func.inc +++ b/program/steps/addressbook/func.inc @@ -212,10 +212,11 @@ function rcmail_directory_list($attrib) { global $RCMAIL, $OUTPUT; - if (!$attrib['id']) + if (!$attrib['id']) { $attrib['id'] = 'rcmdirectorylist'; + } - $out = ''; + $out = ''; $jsdata = array(); $line_templ = html::tag('li', array( diff --git a/program/steps/addressbook/move.inc b/program/steps/addressbook/move.inc index 0de7d31d0..db54d142f 100644 --- a/program/steps/addressbook/move.inc +++ b/program/steps/addressbook/move.inc @@ -53,7 +53,7 @@ foreach ($cids as $source => $source_cids) { break; } - if (!$CONTACTS || !$CONTACTS->ready || $CONTACTS->readonly) { + if (!$CONTACTS || !$CONTACTS->ready || ($CONTACTS->readonly && empty($CONTACTS->deletable))) { continue; } diff --git a/program/steps/addressbook/search.inc b/program/steps/addressbook/search.inc index 1564a3eeb..aa881dbca 100644 --- a/program/steps/addressbook/search.inc +++ b/program/steps/addressbook/search.inc @@ -144,7 +144,7 @@ function rcmail_contact_search() $search_set = array(); $records = array(); $sort_col = $RCMAIL->config->get('addressbook_sort_col', 'name'); - $afields = $RCMAIL->config->get('contactlist_fields'); + $afields = $RCMAIL->config->get('contactlist_fields'); foreach ($sources as $s) { $source = $RCMAIL->get_address_book($s['id']); diff --git a/program/steps/mail/addcontact.inc b/program/steps/mail/addcontact.inc index 3193b4177..03947b423 100644 --- a/program/steps/mail/addcontact.inc +++ b/program/steps/mail/addcontact.inc @@ -22,74 +22,60 @@ if (!$OUTPUT->ajax_call) { return; } -// Get default addressbook -$CONTACTS = $RCMAIL->get_address_book(-1, true); +// Get the default addressbook +$CONTACTS = $RCMAIL->get_address_book(rcube_addressbook::TYPE_DEFAULT, true); +$SENDERS = null; +$type = rcube_addressbook::TYPE_DEFAULT; -if (!empty($_POST['_address']) && is_object($CONTACTS)) { - $address = rcube_utils::get_input_value('_address', rcube_utils::INPUT_POST, true); - $contact_arr = rcube_mime::decode_address_list($address, 1, false); +// Get the trusted senders addressbook +if (!empty($_POST['_reload'])) { + $collected_senders = $rcmail->config->get('collected_senders'); - if (!empty($contact_arr[1]['mailto'])) { - $contact = array( - 'email' => $contact_arr[1]['mailto'], - 'name' => $contact_arr[1]['name'], - ); - - // Validity checks - if (empty($contact['email'])) { - $OUTPUT->show_message('errorsavingcontact', 'error'); - $OUTPUT->send(); - } - - $email = rcube_utils::idn_to_ascii($contact['email']); - if (!rcube_utils::check_email($email, false)) { - $OUTPUT->show_message('emailformaterror', 'error', array('email' => $contact['email'])); - $OUTPUT->send(); - } - - $contact['email'] = rcube_utils::idn_to_utf8($contact['email']); - - $contact = $RCMAIL->plugins->exec_hook('contact_displayname', $contact); - - if (empty($contact['firstname']) || empty($contact['surname'])) { - $contact['name'] = rcube_addressbook::compose_display_name($contact); - } - - // validate contact record - if (!$CONTACTS->validate($contact, true)) { - $error = $CONTACTS->get_error(); - // TODO: show dialog to complete record - // if ($error['type'] == rcube_addressbook::ERROR_VALIDATE) { } - - $OUTPUT->show_message($error['message'] ?: 'errorsavingcontact', 'error'); - $OUTPUT->send(); - } - - // check for existing contacts - $existing = $CONTACTS->search('email', $contact['email'], 1, false); - - if ($done = $existing->count) { - $OUTPUT->show_message('contactexists', 'warning'); - } - else { - $plugin = $RCMAIL->plugins->exec_hook('contact_create', array('record' => $contact, 'source' => null)); - $contact = $plugin['record']; - - $done = !$plugin['abort'] ? $CONTACTS->insert($contact) : $plugin['result']; - - if ($done) { - $OUTPUT->show_message('addedsuccessfully', 'confirmation'); - - if (!empty($_POST['_reload'])) { - $OUTPUT->command('command', 'load-remote'); - } - } + if (strlen($collected_senders)) { + $type |= rcube_addressbook::TYPE_TRUSTED_SENDER; + $SENDERS = $RCMAIL->get_address_book($collected_senders); + if ($CONTACTS == $SENDERS) { + $SENDERS = null; } } } -if (!$done) { - $OUTPUT->show_message($plugin['message'] ?: 'errorsavingcontact', 'error'); +$address = rcube_utils::get_input_value('_address', rcube_utils::INPUT_POST, true); +$contact = rcube_mime::decode_address_list($address, 1, false); + +if (empty($contact[1]['mailto'])) { + $OUTPUT->show_message('errorsavingcontact', 'error'); + $OUTPUT->send(); +} + +$contact = array( + 'email' => $contact[1]['mailto'], + 'name' => $contact[1]['name'], +); + +$email = rcube_utils::idn_to_ascii($contact['email']); + +if (!rcube_utils::check_email($email, false)) { + $OUTPUT->show_message('emailformaterror', 'error', array('email' => $contact['email'])); + $OUTPUT->send(); +} + +if ($RCMAIL->contact_exists($contact['email'], $type)) { + $OUTPUT->show_message('contactexists', 'warning'); + $OUTPUT->send(); +} + +$done = $RCMAIL->contact_create($contact, $SENDERS ?: $CONTACTS, $error); + +if ($done) { + $OUTPUT->show_message('addedsuccessfully', 'confirmation'); + + if (!empty($_POST['_reload'])) { + $OUTPUT->command('command', 'load-remote'); + } +} +else { + $OUTPUT->show_message($error ?: 'errorsavingcontact', 'error'); } $OUTPUT->send(); diff --git a/program/steps/mail/autocomplete.inc b/program/steps/mail/autocomplete.inc index 0a0b47cc3..e7c1d95bc 100644 --- a/program/steps/mail/autocomplete.inc +++ b/program/steps/mail/autocomplete.inc @@ -12,8 +12,8 @@ | See the README file for a full license statement. | | | | PURPOSE: | - | Perform a search on configured address books for the address | - | autocompletion of the message compose screen | + | Perform a search on configured address books for the email | + | address autocompletion | +-----------------------------------------------------------------------+ | Author: Thomas Bruederli | +-----------------------------------------------------------------------+ @@ -41,23 +41,15 @@ if ($RCMAIL->action == 'group-expand') { $OUTPUT->send(); } - $MAXNUM = (int) $RCMAIL->config->get('autocomplete_max', 15); $mode = (int) $RCMAIL->config->get('addressbook_search_mode'); $single = (bool) $RCMAIL->config->get('autocomplete_single'); $search = rcube_utils::get_input_value('_search', rcube_utils::INPUT_GPC, true); -$source = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC); $reqid = rcube_utils::get_input_value('_reqid', rcube_utils::INPUT_GPC); -if (strlen($source)) { - $book_types = array($source); -} -else { - $book_types = (array) $RCMAIL->config->get('autocomplete_addressbooks', 'sql'); -} - $contacts = array(); -if (!empty($book_types) && strlen($search)) { + +if (strlen($search) && ($book_types = rcmail_autocomplete_addressbooks())) { $sort_keys = array(); $books_num = count($book_types); $search_lc = mb_strtolower($search); @@ -148,7 +140,7 @@ if (!empty($book_types) && strlen($search)) { ); if (count($contacts) >= $MAXNUM) { - break 2; + break 3; } } } @@ -165,7 +157,7 @@ if (!empty($book_types) && strlen($search)) { ); if (count($contacts) >= $MAXNUM) { - break; + break 2; } } } @@ -186,12 +178,43 @@ if (!empty($book_types) && strlen($search)) { // Allow autocomplete result optimization via plugin -$pluginResult = $RCMAIL->plugins->exec_hook('contacts_autocomplete_after', array( +$plugin = $RCMAIL->plugins->exec_hook('contacts_autocomplete_after', array( 'search' => $search, 'contacts' => $contacts, // Provide already-found contacts to plugin if they are required )); -$contacts = $pluginResult['contacts']; +$contacts = $plugin['contacts']; $OUTPUT->command('ksearch_query_results', $contacts, $search, $reqid); $OUTPUT->send(); + + +/** + * Collect addressbook sources used for autocompletion + */ +function rcmail_autocomplete_addressbooks() +{ + global $RCMAIL; + + $source = rcube_utils::get_input_value('_source', rcube_utils::INPUT_GPC); + + if (strlen($source)) { + $book_types = array($source); + } + else { + $book_types = (array) $RCMAIL->config->get('autocomplete_addressbooks', 'sql'); + } + + $collected_recipients = $RCMAIL->config->get('collected_recipients'); + $collected_senders = $RCMAIL->config->get('collected_senders'); + + if (strlen($collected_recipients) && !in_array($collected_recipients, $book_types)) { + $book_types[] = $collected_recipients; + } + + if (strlen($collected_senders) && !in_array($collected_senders, $book_types)) { + $book_types[] = $collected_senders; + } + + return !empty($book_types) ? $book_types : null; +} diff --git a/program/steps/mail/func.inc b/program/steps/mail/func.inc index 1fa1c7e3b..bd6b0210f 100644 --- a/program/steps/mail/func.inc +++ b/program/steps/mail/func.inc @@ -798,12 +798,9 @@ function rcmail_check_safe($message) ) { switch ($show_images) { case 1: // known senders only - // get default addressbook, like in addcontact.inc - $CONTACTS = $RCMAIL->get_address_book(-1, true); - - if ($CONTACTS && $message->sender['mailto']) { - $result = $CONTACTS->search('email', $message->sender['mailto'], 1, false); - if ($result->count) { + if (!empty($message->sender['mailto'])) { + $type = rcube_addressbook::TYPE_DEFAULT | rcube_addressbook::TYPE_TRUSTED_SENDER; + if ($RCMAIL->contact_exists($message->sender['mailto'], $type)) { $message->set_safe(true); } } diff --git a/program/steps/mail/show.inc b/program/steps/mail/show.inc index 292ba9475..3012a5d2d 100644 --- a/program/steps/mail/show.inc +++ b/program/steps/mail/show.inc @@ -116,7 +116,9 @@ if ($uid) { ) { $mdn_cfg = intval($RCMAIL->config->get('mdn_requests')); - if ($mdn_cfg == 1 || (($mdn_cfg == 3 || $mdn_cfg == 4) && rcmail_contact_exists($MESSAGE->sender['mailto']))) { + if ($mdn_cfg == 1 || (($mdn_cfg == 3 || $mdn_cfg == 4) + && $RCMAIL->contact_exists($MESSAGE->sender['mailto'], rcube_addressbook::TYPE_DEFAULT)) + ) { // Send MDN if (rcmail_send_mdn($MESSAGE, $smtp_error)) { $OUTPUT->show_message('receiptsent', 'confirmation'); @@ -332,25 +334,6 @@ function rcmail_message_objects($attrib) return html::div($attrib, $content); } -function rcmail_contact_exists($email) -{ - global $RCMAIL; - - if ($email) { - // @TODO: search in all address books? - $CONTACTS = $RCMAIL->get_address_book(-1, true); - - if (is_object($CONTACTS)) { - $existing = $CONTACTS->search('email', $email, 1, false); - if ($existing->count) { - return true; - } - } - } - - return false; -} - function rcmail_message_contactphoto($attrib) { global $RCMAIL, $MESSAGE; diff --git a/program/steps/settings/func.inc b/program/steps/settings/func.inc index 05312ada1..8eb97f153 100644 --- a/program/steps/settings/func.inc +++ b/program/steps/settings/func.inc @@ -977,8 +977,9 @@ function rcmail_user_prefs($current = null) // Addressbook config case 'addressbook': $blocks = array( - 'main' => array('name' => rcube::Q($RCMAIL->gettext('mainoptions'))), - 'advanced' => array('name' => rcube::Q($RCMAIL->gettext('advancedoptions'))), + 'main' => array('name' => rcube::Q($RCMAIL->gettext('mainoptions'))), + 'collected' => array('name' => rcube::Q($RCMAIL->gettext('collectedaddresses'))), + 'advanced' => array('name' => rcube::Q($RCMAIL->gettext('advancedoptions'))), ); if (!isset($no_override['default_addressbook']) @@ -1069,6 +1070,66 @@ function rcmail_user_prefs($current = null) 'content' => $checkbox->show($config['autocomplete_single']?1:0), ); } + + if (!isset($no_override['collected_recipients'])) { + if (!$current) { + continue 2; + } + + if (!isset($books)) { + $books = $RCMAIL->get_address_sources(true, true); + } + + $field_id = 'rcmfd_collected_recipients'; + $select = new html_select(array('name' => '_collected_recipients', 'id' => $field_id, 'class' => 'custom-select')); + + $select->add('---', ''); + $select->add($RCMAIL->gettext('collectedrecipients'), (string) rcube_addressbook::TYPE_RECIPIENT); + + foreach ($books as $book) { + $select->add(html_entity_decode($book['name'], ENT_COMPAT, 'UTF-8'), $book['id']); + } + + $selected = $config['collected_recipients']; + if (is_bool($selected)) { + $selected = $selected ? rcube_addressbook::TYPE_RECIPIENT : ''; + } + + $blocks['collected']['options']['collected_recipients'] = array( + 'title' => html::label($field_id, rcube::Q($RCMAIL->gettext('collectedrecipientsopt'))), + 'content' => $select->show((string) $selected), + ); + } + + if (!isset($no_override['collected_senders'])) { + if (!$current) { + continue 2; + } + + if (!isset($books)) { + $books = $RCMAIL->get_address_sources(true, true); + } + + $field_id = 'rcmfd_collected_senders'; + $select = new html_select(array('name' => '_collected_senders', 'id' => $field_id, 'class' => 'custom-select')); + + $select->add($RCMAIL->gettext('trustedsenders'), (string) rcube_addressbook::TYPE_TRUSTED_SENDER); + + foreach ($books as $book) { + $select->add(html_entity_decode($book['name'], ENT_COMPAT, 'UTF-8'), $book['id']); + } + + $selected = $config['collected_senders']; + if (is_bool($selected)) { + $selected = $selected ? rcube_addressbook::TYPE_TRUSTED_SENDER : ''; + } + + $blocks['collected']['options']['collected_senders'] = array( + 'title' => html::label($field_id, rcube::Q($RCMAIL->gettext('collectedsendersopt'))), + 'content' => $select->show((string) $elected), + ); + } + break; // Special IMAP folders diff --git a/program/steps/settings/save_prefs.inc b/program/steps/settings/save_prefs.inc index fdbb5eed0..dbf22c2d5 100644 --- a/program/steps/settings/save_prefs.inc +++ b/program/steps/settings/save_prefs.inc @@ -100,6 +100,8 @@ case 'compose': case 'addressbook': $a_user_prefs = array( 'default_addressbook' => rcube_utils::get_input_value('_default_addressbook', rcube_utils::INPUT_POST, true), + 'collected_recipients' => rcube_utils::get_input_value('_collected_recipients', rcube_utils::INPUT_POST, true), + 'collected_senders' => rcube_utils::get_input_value('_collected_senders', rcube_utils::INPUT_POST, true), 'autocomplete_single' => isset($_POST['_autocomplete_single']), 'addressbook_sort_col' => rcmail_prefs_input('addressbook_sort_col', '/^[a-z_]+$/'), 'addressbook_name_listing' => intval($_POST['_addressbook_name_listing']), diff --git a/tests/Framework/Addresses.php b/tests/Framework/Addresses.php new file mode 100644 index 000000000..f29b73cea --- /dev/null +++ b/tests/Framework/Addresses.php @@ -0,0 +1,21 @@ +assertInstanceOf('rcube_addresses', $object, "Class constructor"); + $this->assertInstanceOf('rcube_addressbook', $object, "Class constructor"); + } +}