├── .gitignore ├── README.md ├── composer.json └── src ├── Client.php ├── Email.php ├── EmailAddress.php ├── EmailFactory.php └── Query ├── Query.php └── QueryBuilder.php /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | bin/ 3 | composer.phar 4 | composer.lock 5 | .vagrant/ 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # imapi 2 | 3 | **This library is experimental and not meant to be reused.** 4 | 5 | imapi is a high level IMAP API for PHP. 6 | 7 | It aims to be different from other implementations: 8 | 9 | - **be very high level**: you don't have to know how IMAP works (because IMAP is very ugly) 10 | - take care of related problems like **parse MIME email content** or **sanitize HTML in emails** 11 | - based on Horde's IMAP library rather than on PHP's IMAP extension (explained below) 12 | - be full featured, yet leave the door open for low-level calls to Horde's library for uncovered features 13 | - be maintained (unfortunately IMAP is not a very active topic and many good projects are unfinished or dead) 14 | 15 | It is not based on PHP's IMAP extension, but rather on the amazing Horde library. The reason is well explained 16 | on [Horde's library page](http://dev.horde.org/imap_client/): 17 | 18 | > Horde/Imap_Client is significantly faster, more feature-rich, and extensible when compared to PHP's imap (c-client) extension. 19 | 20 | > Don't be confused: almost every so-called "PHP IMAP Library" out there is nothing more than a thin-wrapper around the imap extension, so NONE of these libraries can fix the basic limitations of that extension. 21 | 22 | ## Getting started 23 | 24 | ``` 25 | composer require mnapoli/imapi 26 | ``` 27 | 28 | The easy way: 29 | 30 | ```php 31 | $client = Imapi\Client::connect('imap.host.com', 'user', 'password'); 32 | ``` 33 | 34 | If you want full control on the connection, you can use Horde's constructor: 35 | 36 | ```php 37 | $hordeClient = new Horde_Imap_Client_Socket([ 38 | 'username' => $username, 39 | 'password' => $password, 40 | 'hostspec' => $host, 41 | 'port' => '143', 42 | 'secure' => 'tls', 43 | ]); 44 | 45 | $client = new Imapi\Client($hordeClient); 46 | ``` 47 | 48 | 49 | ## Reading 50 | 51 | ### Reading the inbox 52 | 53 | Fetching all the messages from the inbox: 54 | 55 | ```php 56 | $emails = $client->getEmails(); 57 | 58 | foreach ($emails as $email) { 59 | echo $email->getSubject(); 60 | } 61 | ``` 62 | 63 | Yes it's that easy. Emails are objects (`Imapi\Email`) that expose all the information of the email. 64 | 65 | If you need to synchronize emails stored locally with the IMAP server, you will probably not want to fetch the emails, 66 | i.e. their content. You can fetch only their ID, which is much faster: 67 | 68 | ```php 69 | $ids = $client->getEmailIds(); 70 | 71 | foreach ($ids as $id) { 72 | if (/* this email needs to be synced */) { 73 | $email = $client->getEmailFromId($id); 74 | // ... 75 | } 76 | } 77 | ``` 78 | 79 | ### Advanced queries 80 | 81 | Both `getEmails()` and `getEmailIds()` can take an optional `Query` object. 82 | 83 | ```php 84 | // Read from the `INBOX.Sent` folder 85 | $query = QueryBuilder::create('INBOX.Sent') 86 | ->youngerThan(3600) // 1 hour 87 | ->flagSeen(true) // return messages with \\seen flag set, or false for messages with seen flag off. 88 | // more options are flagAnswered(boolean), flagDeleted(boolean),flagDraft(boolean),flagFlaged(boolean),flagRecent(boolean) 89 | ->getQuery(); 90 | 91 | $emails = $client->getEmails($query); 92 | ``` 93 | 94 | ### Reading folders 95 | 96 | ```php 97 | $folders = $client->getFolders(); 98 | ``` 99 | 100 | 101 | ## Operations 102 | 103 | ### Moving emails 104 | 105 | ```php 106 | $emailIds = ['123', '456']; 107 | 108 | // Moving from the INBOX to the Archive folder 109 | $client->moveEmails($emailIds, 'INBOX', 'Archive'); 110 | ``` 111 | 112 | ### Deleting emails 113 | 114 | "Deleting" means simply moving to the trash folder. Unfortunately, the trash folder is custom to each provider, 115 | so you need to explicitly provide it: 116 | 117 | ```php 118 | $emailIds = ['123', '456']; 119 | 120 | $client->deleteEmails($emailIds, 'Deleted Messages'); 121 | ``` 122 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mnapoli/imapi", 3 | "description": "IMAP API", 4 | "license": "MIT", 5 | "type": "library", 6 | "autoload": { 7 | "psr-4": { 8 | "Imapi\\": "src/" 9 | } 10 | }, 11 | "autoload-dev": { 12 | "psr-4": { 13 | "Imapi\\Test\\": "tests/" 14 | } 15 | }, 16 | "require": { 17 | "php": "~7.0", 18 | "pear-pear.horde.org/Horde_Imap_Client": "~2.19", 19 | "php-mime-mail-parser/php-mime-mail-parser": "~2.0", 20 | "ezyang/htmlpurifier": "~4.7" 21 | }, 22 | "require-dev": { 23 | "phpunit/phpunit": "~6.4" 24 | }, 25 | "repositories": [ 26 | { 27 | "type": "pear", 28 | "url": "https://pear.horde.org" 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class Client 17 | { 18 | /** 19 | * @var Horde_Imap_Client_Socket 20 | */ 21 | private $hordeClient; 22 | 23 | /** 24 | * @var EmailFactory 25 | */ 26 | private $emailFactory; 27 | 28 | public function __construct(Horde_Imap_Client_Socket $hordeClient, EmailFactory $emailFactory = null) 29 | { 30 | $this->hordeClient = $hordeClient; 31 | $this->emailFactory = $emailFactory ?: new EmailFactory(); 32 | } 33 | 34 | /** 35 | * Connect to a remote IMAP server and return the client instance. 36 | */ 37 | public static function connect( 38 | string $host, 39 | string $username, 40 | string $password, 41 | string $port = '143', 42 | string $secure = 'tls' 43 | ) : self 44 | { 45 | $hordeClient = new Horde_Imap_Client_Socket([ 46 | 'username' => $username, 47 | 'password' => $password, 48 | 'hostspec' => $host, 49 | 'port' => $port, 50 | 'secure' => $secure, 51 | ]); 52 | 53 | return new static($hordeClient); 54 | } 55 | 56 | /** 57 | * Returns the list of folders in the account. 58 | * 59 | * @return string[] 60 | */ 61 | public function getFolders() : array 62 | { 63 | return array_keys($this->hordeClient->listMailboxes('*')); 64 | } 65 | 66 | /** 67 | * Finds the emails matching the query. If $query is null, then it will fetch the emails in the inbox. 68 | * 69 | * @return Email[] 70 | */ 71 | public function getEmails(Query $query = null) : array 72 | { 73 | $hordeQuery = new Horde_Imap_Client_Search_Query(); 74 | 75 | $query = $query ?: new Query; 76 | 77 | if ($query->getYoungerThan() !== null) { 78 | $hordeQuery->intervalSearch( 79 | $query->getYoungerThan(), 80 | Horde_Imap_Client_Search_Query::INTERVAL_YOUNGER 81 | ); 82 | } 83 | 84 | $this->setFlags($hordeQuery, $query); 85 | return $this->searchAndFetch($query->getFolder(), $hordeQuery); 86 | } 87 | 88 | /** 89 | * Finds the email Ids matching the query. If $query is null, then it will fetch the email Ids in the inbox. 90 | * 91 | * This method is obviously more efficient than getEmails() if you want to synchronize local mails. 92 | * 93 | * @return string[] 94 | */ 95 | public function getEmailIds(Query $query = null) : array 96 | { 97 | $hordeQuery = new Horde_Imap_Client_Search_Query(); 98 | 99 | $query = $query ?: new Query; 100 | 101 | if ($query->getYoungerThan() !== null) { 102 | $hordeQuery->intervalSearch( 103 | $query->getYoungerThan(), 104 | Horde_Imap_Client_Search_Query::INTERVAL_YOUNGER 105 | ); 106 | } 107 | 108 | $this->setFlags($hordeQuery, $query); 109 | return $this->search($query->getFolder(), $hordeQuery); 110 | } 111 | 112 | /** 113 | * @return Email|null Returns null if the email was not found. 114 | */ 115 | public function getEmailFromId(string $id, string $folder = 'INBOX') 116 | { 117 | $emails = $this->fetchEmails($folder, [$id]); 118 | 119 | return (count($emails) > 0) ? $emails[0] : null; 120 | } 121 | 122 | /** 123 | * @param string[] $ids 124 | * @return Email[] 125 | */ 126 | public function getEmailsFromId(array $ids, string $folder = 'INBOX') : array 127 | { 128 | return $this->fetchEmails($folder, $ids); 129 | } 130 | 131 | /** 132 | * Move emails from one folder to another. 133 | * 134 | * @param int[] $ids 135 | */ 136 | public function moveEmails(array $ids, string $from, string $to) 137 | { 138 | $this->hordeClient->copy((string) $from, (string) $to, [ 139 | 'ids' => new Horde_Imap_Client_Ids($ids), 140 | 'move' => true, 141 | ]); 142 | } 143 | 144 | /** 145 | * Delete emails by moving them to the trash folder. 146 | * 147 | * @param int[] $ids 148 | * @param string $trashFolder Trash folder. There is no standard default, it can be 'Deleted Messages', 'Trash'… 149 | * @param string $fromFolder Folder from which the email Ids come from. 150 | */ 151 | public function deleteEmails(array $ids, $trashFolder, $fromFolder = 'INBOX') 152 | { 153 | $this->moveEmails($ids, $fromFolder, $trashFolder); 154 | } 155 | 156 | /** 157 | * @return Email[] 158 | */ 159 | private function searchAndFetch(string $folder, Horde_Imap_Client_Search_Query $query) : array 160 | { 161 | return $this->fetchEmails($folder, $this->search($folder, $query)); 162 | } 163 | 164 | /** 165 | * @return int[] 166 | */ 167 | private function search(string $folder, Horde_Imap_Client_Search_Query $query) : array 168 | { 169 | $results = $this->hordeClient->search($folder, $query); 170 | /** @var Horde_Imap_Client_Ids $results */ 171 | $results = $results['match']; 172 | 173 | return $results->ids; 174 | } 175 | 176 | private function fetchEmails(string $folder, array $ids) : array 177 | { 178 | $query = new Horde_Imap_Client_Fetch_Query(); 179 | $query->envelope(); 180 | $query->fullText([ 181 | 'peek' => true, 182 | ]); 183 | $query->flags(); 184 | 185 | $hordeEmails = $this->hordeClient->fetch($folder, $query, [ 186 | 'ids' => new Horde_Imap_Client_Ids($ids) 187 | ]); 188 | 189 | return $this->emailFactory->createMany($folder, $hordeEmails); 190 | } 191 | 192 | public function getHordeClient() : Horde_Imap_Client_Socket 193 | { 194 | return $this->hordeClient; 195 | } 196 | 197 | private function setFlags(Horde_Imap_Client_Search_Query $hordeQuery,Query $query){ 198 | if(count($query->getFlags()) > 0){ 199 | foreach ($query->getFlags() as $key => $value){ 200 | switch ($key){ 201 | case Query::FLAG_ANSWERED: 202 | $hordeQuery->flag(\Horde_Imap_Client::FLAG_ANSWERED, $value); 203 | break; 204 | case Query::FLAG_DELETED: 205 | $hordeQuery->flag(\Horde_Imap_Client::FLAG_DELETED, $value); 206 | break; 207 | case Query::FLAG_DRAFT: 208 | $hordeQuery->flag(\Horde_Imap_Client::FLAG_DRAFT, $value); 209 | break; 210 | case Query::FLAG_FLAGED: 211 | $hordeQuery->flag(\Horde_Imap_Client::FLAG_FLAGGED, $value); 212 | break; 213 | case Query::FLAG_RECENT: 214 | $hordeQuery->flag(\Horde_Imap_Client::FLAG_RECENT, $value); 215 | break; 216 | case Query::FLAG_SEEN: 217 | $hordeQuery->flag(\Horde_Imap_Client::FLAG_SEEN, $value); 218 | break; 219 | } 220 | } 221 | } 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/Email.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | class Email 14 | { 15 | /** 16 | * @var string 17 | */ 18 | private $uid; 19 | 20 | /** 21 | * @var string|null 22 | */ 23 | private $messageId; 24 | 25 | /** 26 | * @var string 27 | */ 28 | private $mailbox; 29 | 30 | /** 31 | * @var string 32 | */ 33 | private $subject; 34 | 35 | /** 36 | * @var string 37 | */ 38 | private $htmlContent; 39 | 40 | /** 41 | * @var string 42 | */ 43 | private $textContent; 44 | 45 | /** 46 | * @var DateTime|null 47 | */ 48 | private $date; 49 | 50 | /** 51 | * @var EmailAddress[] 52 | */ 53 | private $from = []; 54 | 55 | /** 56 | * @var EmailAddress[] 57 | */ 58 | private $to = []; 59 | 60 | /** 61 | * @var bool 62 | */ 63 | private $read = false; 64 | 65 | /** 66 | * @var string|null 67 | */ 68 | private $inReplyTo; 69 | 70 | /** 71 | * @param string|null $messageId 72 | * @param EmailAddress[] $from 73 | * @param EmailAddress[] $to 74 | * @param string|null $inReplyTo 75 | */ 76 | public function __construct( 77 | string $uid, 78 | $messageId, 79 | string $mailbox, 80 | string $subject, 81 | string $htmlContent, 82 | string $textContent, 83 | array $from = [], 84 | array $to = [], 85 | $inReplyTo 86 | ) { 87 | $this->uid = $uid; 88 | $this->messageId = $messageId; 89 | $this->mailbox = $mailbox; 90 | $this->subject = $subject; 91 | $this->htmlContent = $htmlContent; 92 | $this->textContent = $textContent; 93 | $this->from = $from; 94 | $this->to = $to; 95 | $this->inReplyTo = $inReplyTo; 96 | } 97 | 98 | /** 99 | * Returns the UID of the IMAP message. 100 | * 101 | * This ID is set by the IMAP server. 102 | * 103 | * UID of the email may be not unique on the server (2 messages in different folders may have same UID). 104 | * 105 | * @see http://www.limilabs.com/blog/unique-id-in-imap-protocol 106 | */ 107 | public function getUid() : string 108 | { 109 | return $this->uid; 110 | } 111 | 112 | /** 113 | * Returns the "Message-ID" header. 114 | * 115 | * @see https://en.wikipedia.org/wiki/Message-ID 116 | * @return string|null 117 | */ 118 | public function getMessageId() 119 | { 120 | return $this->messageId; 121 | } 122 | 123 | public function getMailbox() : string 124 | { 125 | return $this->mailbox; 126 | } 127 | 128 | public function getSubject() : string 129 | { 130 | return $this->subject; 131 | } 132 | 133 | public function getHtmlContent() : string 134 | { 135 | return $this->htmlContent; 136 | } 137 | 138 | public function getTextContent() : string 139 | { 140 | return $this->textContent; 141 | } 142 | 143 | /** 144 | * @return DateTime|null 145 | */ 146 | public function getDate() 147 | { 148 | return $this->date; 149 | } 150 | 151 | public function setDate(DateTime $date) 152 | { 153 | $this->date = $date; 154 | } 155 | 156 | /** 157 | * @return EmailAddress[] 158 | */ 159 | public function getFrom() : array 160 | { 161 | return $this->from; 162 | } 163 | 164 | /** 165 | * @return EmailAddress[] 166 | */ 167 | public function getTo() : array 168 | { 169 | return $this->to; 170 | } 171 | 172 | public function setRead(bool $read) 173 | { 174 | $this->read = $read; 175 | } 176 | 177 | public function isRead() : bool 178 | { 179 | return $this->read; 180 | } 181 | 182 | /** 183 | * Message ID of the email this email is a reply to. 184 | * 185 | * @return string|null 186 | */ 187 | public function getInReplyTo() 188 | { 189 | return $this->inReplyTo; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/EmailAddress.php: -------------------------------------------------------------------------------- 1 | 9 | * 10 | * @author Matthieu Napoli 11 | */ 12 | class EmailAddress 13 | { 14 | /** 15 | * @var string 16 | */ 17 | private $email; 18 | 19 | /** 20 | * @var string|null 21 | */ 22 | private $name; 23 | 24 | public function __construct(string $email, string $name = null) 25 | { 26 | $this->email = $email; 27 | $this->name = $name; 28 | } 29 | 30 | public function getEmail() : string 31 | { 32 | return $this->email; 33 | } 34 | 35 | /** 36 | * @return string|null 37 | */ 38 | public function getName() 39 | { 40 | return $this->name; 41 | } 42 | 43 | public function getNameOrEmail() : string 44 | { 45 | return $this->name ?: $this->email; 46 | } 47 | 48 | public function __toString() : string 49 | { 50 | if ($this->name == null) { 51 | return $this->email; 52 | } 53 | 54 | return sprintf('"%s" <%s>', $this->name, $this->email); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/EmailFactory.php: -------------------------------------------------------------------------------- 1 | htmlFilter = $htmlFilter ?: $this->createHTMLPurifier(); 25 | } 26 | 27 | public function create(string $mailbox, Horde_Imap_Client_Data_Fetch $hordeEmail) : Email 28 | { 29 | // Parse the message body 30 | $parser = new Parser(); 31 | $parser->setText($hordeEmail->getFullMsg()); 32 | $htmlContent = (string) $parser->getMessageBody('html'); 33 | $textContent = (string) $parser->getMessageBody('text'); 34 | 35 | // Filter HTML body to have only safe HTML 36 | $htmlContent = trim($this->htmlFilter->purify($htmlContent)); 37 | 38 | // If no HTML content, use the text content 39 | if ($htmlContent == '') { 40 | $htmlContent = nl2br($textContent); 41 | } 42 | 43 | // The envelope contains the headers 44 | $envelope = $hordeEmail->getEnvelope(); 45 | 46 | $from = []; 47 | foreach ($envelope->from as $hordeFrom) { 48 | /** @var Horde_Mail_Rfc822_Address $hordeFrom */ 49 | if ($hordeFrom->bare_address) { 50 | $from[] = new EmailAddress($hordeFrom->bare_address, $hordeFrom->personal); 51 | } 52 | } 53 | $to = []; 54 | foreach ($envelope->to as $hordeTo) { 55 | /** @var Horde_Mail_Rfc822_Address $hordeTo */ 56 | if ($hordeTo->bare_address) { 57 | $to[] = new EmailAddress($hordeTo->bare_address, $hordeTo->personal); 58 | } 59 | } 60 | 61 | $messageId = $this->parseMessageId($envelope->message_id); 62 | $inReplyTo = $this->parseMessageId($envelope->in_reply_to); 63 | 64 | $message = new Email( 65 | (string) $hordeEmail->getUid(), 66 | $messageId, 67 | $mailbox, 68 | $envelope->subject, 69 | $htmlContent, 70 | $textContent, 71 | $from, 72 | $to, 73 | $inReplyTo 74 | ); 75 | 76 | $date = new DateTime(); 77 | $date->setTimestamp($envelope->date->getTimestamp()); 78 | $message->setDate($date); 79 | 80 | $flags = $hordeEmail->getFlags(); 81 | if (in_array(Horde_Imap_Client::FLAG_SEEN, $flags)) { 82 | $message->setRead(true); 83 | } 84 | 85 | return $message; 86 | } 87 | 88 | /** 89 | * @param Horde_Imap_Client_Data_Fetch[]|Horde_Imap_Client_Fetch_Results $hordeEmails 90 | * @return Email[] 91 | */ 92 | public function createMany(string $mailbox, $hordeEmails) : array 93 | { 94 | $emails = []; 95 | 96 | foreach ($hordeEmails as $hordeEmail) { 97 | $emails[] = $this->create($mailbox, $hordeEmail); 98 | } 99 | 100 | return $emails; 101 | } 102 | 103 | private function createHTMLPurifier() : HTMLPurifier 104 | { 105 | return new HTMLPurifier(HTMLPurifier_Config::createDefault()); 106 | } 107 | 108 | /** 109 | * @param string|null $messageId 110 | * @return string|null 111 | */ 112 | private function parseMessageId($messageId) 113 | { 114 | if (!$messageId) { 115 | return null; 116 | } 117 | 118 | $result = preg_match('/<([^>]*)>/', $messageId, $matches); 119 | 120 | if ($result === false) { 121 | throw new \Exception('Unexpected error while parsing message ID ' . $messageId); 122 | } 123 | if ($result === 0) { 124 | return null; 125 | } 126 | 127 | return $matches[1]; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Query/Query.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class Query 11 | { 12 | /** 13 | * @var string 14 | */ 15 | private $folder = 'INBOX'; 16 | 17 | /** 18 | * In seconds. 19 | * @var int 20 | */ 21 | private $youngerThan; 22 | 23 | /** 24 | * @var array 25 | */ 26 | private $flags = []; 27 | 28 | public function getFolder() : string 29 | { 30 | return $this->folder; 31 | } 32 | 33 | public function setFolder(string $folder) 34 | { 35 | $this->folder = $folder; 36 | } 37 | 38 | /** 39 | * @return int|null 40 | */ 41 | public function getYoungerThan() 42 | { 43 | return $this->youngerThan; 44 | } 45 | 46 | /** 47 | * @param int $youngerThan Number of seconds (e.g. 3600 will return emails of the last hour). 48 | */ 49 | public function setYoungerThan(int $youngerThan) 50 | { 51 | $this->youngerThan = $youngerThan; 52 | } 53 | 54 | public function setFlags($key,$value){ 55 | $this->flags[$key] = $value; 56 | } 57 | 58 | public function getFlags(){ 59 | return $this->flags; 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/Query/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class QueryBuilder 11 | { 12 | /** 13 | * @var Query 14 | */ 15 | private $query; 16 | 17 | public static function create(string $folder = null) : self 18 | { 19 | return new static($folder); 20 | } 21 | 22 | private function __construct(string $folder = null) 23 | { 24 | $this->query = new Query(); 25 | 26 | if ($folder !== null) { 27 | $this->query->setFolder($folder); 28 | } 29 | } 30 | 31 | public function getQuery() : Query 32 | { 33 | return $this->query; 34 | } 35 | 36 | /** 37 | * @param int $interval Number of seconds (e.g. 3600 will return emails of the last hour). 38 | */ 39 | public function youngerThan(int $interval) : self 40 | { 41 | $this->query->setYoungerThan($interval); 42 | 43 | return $this; 44 | } 45 | 46 | public function flagAnswered($value) { 47 | $this->query->setFlags(Query::FLAG_ANSWERED, $value); 48 | return $this; 49 | } 50 | 51 | public function flagDeleted($value) { 52 | $this->query->setFlags(Query::FLAG_DELETED, $value); 53 | return $this; 54 | } 55 | 56 | public function flagDraft($value) { 57 | $this->query->setFlags(Query::FLAG_DRAFT, $value); 58 | return $this; 59 | } 60 | 61 | public function flagFlaged($value) { 62 | $this->query->setFlags(Query::FLAG_FLAGED, $value); 63 | return $this; 64 | } 65 | 66 | public function flagRecent($value) { 67 | $this->query->setFlags(Query::FLAG_RECENT, $value); 68 | return $this; 69 | } 70 | 71 | public function flagSeen($value) { 72 | $this->query->setFlags(Query::FLAG_SEEN, $value); 73 | return $this; 74 | } 75 | 76 | } 77 | --------------------------------------------------------------------------------