├── .gitignore ├── CHANGELOG.md ├── README.md ├── build.sh ├── create-phar.php ├── imapsync.sh ├── phpimapsync.phar └── src ├── Configuration.php ├── Entity └── Message.php ├── ImapUtility.php ├── Server.php └── phpimapsync.php /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | /*.iml 3 | .idea/ 4 | *.ipr 5 | *.iws 6 | out/ 7 | .idea_modules/ 8 | atlassian-ide-plugin.xml 9 | com_crashlytics_export_strings.xml 10 | error_log -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 0.1 2 | ================== 3 | * This is the first push, I have tested as much as I can and hopefully it is bug free (don't hold me to it), 4 | please let me know. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Imap Sync 2 | 3 | ## Why? 4 | 5 | Well, we had a little problem when migrating clients from other hosts to our servers. Moving the files and MySQL 6 | databases was the easy part! We developed PHP-IMAP-Sync to make it easier to transfer these accounts and even keep our 7 | servers updated while the transfer was going on... 8 | 9 | ## How to Use 10 | 11 | From sh goto the project folder and run: 12 | 13 | php phpimapsync.php --source imap-ssl://account1@domain1.com:password@incoming.mailserver1.com:993/ --target imap-ssl://account2@domain2.com:password@incoming.mailserver2.com:993/. 14 | 15 | Dont forget to change the details. 16 | 17 | ## Requirements 18 | 19 | * PHP 7.4+ 20 | 21 | ## Parameter 22 | 23 | ```text 24 | Required: 25 | --source Source server 26 | --target Target server 27 | 28 | Optional: 29 | -v, --verbose Verbose output 30 | -t, --test, --dry-run Test run, do nothing 31 | --listFolder Only list folder, no synchronizing 32 | -w, --wipe Remove all messages on target 33 | --mapFolder JSON to map between folders 34 | -m, --memory PHP Memory Limit 35 | 36 | Optional overwrite parameters: 37 | --sourceUsername Overwrite source username 38 | --sourcePassword Overwrite source password 39 | --targetUsername Overwrite target username 40 | --targetPassword Overwrite target password 41 | ``` 42 | 43 | ## Available protocols 44 | 45 | * imap-ssl 46 | * imap-ssl-novalidate 47 | * imap-tls 48 | 49 | ## Examples 50 | 51 | Synchronize mails between server: 52 | 53 | ```bash 54 | # Just list folder 55 | imap-sync.phar --listFolder \ 56 | --source '{"url": "imap-ssl://mail.server.org:993/", "username": "user1@website.org", "password": "PassWord"}' \ 57 | --target '{"url": "imap-ssl://mail.server.org:993/", "username": "user2@website.org", "password": "PassWord"}' 58 | 59 | # Synchronize 60 | imap-sync.phar -v \ 61 | --source '{"url": "imap-ssl://mail.server.org:993/", "username": "user1@website.org", "password": "PassWord"}' \ 62 | --target '{"url": "imap-ssl://mail.server.org:993/", "username": "user2@website.org", "password": "PassWord"}' 63 | ``` 64 | 65 | Show folders with folder mapping: 66 | 67 | ```bash 68 | imap-sync.phar --listFolder \ 69 | --source '{"url": "imap-ssl://mail.server.org:993/", "username": "user1@website.org", "password": "PassWord"}' \ 70 | --target '{"url": "imap-ssl://mail.server.org:993/", "username": "user2@website.org", "password": "PassWord"}' \ 71 | --mapFolder '{"Papierkorb": "Trash", "Spam": "Junk", "Gesendete Objekte": "Sent", "Entw&APw-rfe": "Drafts"}' 72 | ``` 73 | 74 | Synchronize with new source markup and old target markup: 75 | 76 | ```bash 77 | imap-sync.phar \ 78 | --source '{"url": "imap-ssl://mail.server.org:993/", "username": "user1@website.org", "password": "PassWord"}' \ 79 | --target 'imap-ssl://user2@website.org:password@mail.server.org:993/' 80 | ``` 81 | 82 | Synchronize and overwrite fields username and password (you should not need that): 83 | 84 | ```bash 85 | imap-sync.phar \ 86 | --source '{"url": "imap-ssl://mail.server.org:993/", "username": "oldUsername", "password": "oldPassword"}' --sourceUsername 'newUsername' --sourcePassword 'newPassword' \ 87 | --target 'imap-ssl://oldUsername:oldPassword@mail.server.org:993/' --targetUsername 'newUsername' --targetPassword 'oldPassword' 88 | ``` 89 | 90 | ## Script example 91 | 92 | ```bash 93 | #!/usr/bin/env bash 94 | set -e 95 | 96 | # Test synchronization, with extra parameters 97 | imap-sync.phar -t -w -m 1024M \ 98 | --source '{"url": "imap-ssl://mail.server.org:993/", "username": "user1@website.org", "password": "PassWord"}' \ 99 | --target '{"url": "imap-ssl://mail.server.org:993/subfolder", "username": "user2@website.org", "password": "PassWord"}' \ 100 | --mapFolder '{"Papierkorb": "Trash", "Spam": "Junk", "Gesendete Objekte": "Sent", "Entw&APw-rfe": "Drafts"}' 101 | ``` 102 | 103 | ## Build package 104 | 105 | ```bash 106 | ./build.sh 107 | ``` 108 | 109 | ## Credits 110 | 111 | Special thanks to David Busby - edoceo, their script worked well, but not well enough for what we wanted to do! 112 | 113 | https://github.com/edoceo/imap-move 114 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | php --define phar.readonly=0 create-phar.php 5 | -------------------------------------------------------------------------------- /create-phar.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | startBuffering(); 12 | $phar->buildFromDirectory(__DIR__ . '/src', '/\.php$/'); 13 | 14 | $phar->setStub('#!/usr/bin/env php' . PHP_EOL . $phar->createDefaultStub($appName . '.php')); 15 | $phar->stopBuffering(); 16 | 17 | exec('chmod +x ' . $filePhar); 18 | -------------------------------------------------------------------------------- /imapsync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -x 2 | 3 | # Example IMAP Sync 4 | 5 | php phpimapsync.php --source imap-ssl://account1@domain1.com:password@incoming.mailserver1.com:993/ --target imap-ssl://account2@domain2.com:password@incoming.mailserver2.com:993/. 6 | -------------------------------------------------------------------------------- /phpimapsync.phar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webforward/PHP-IMAP-Sync/a90690c2419d7d670f1fec3ded98a5d3b8b26daa/phpimapsync.phar -------------------------------------------------------------------------------- /src/Configuration.php: -------------------------------------------------------------------------------- 1 | source = new Server(); 20 | $this->target = new Server(); 21 | $this->parseArgs($argc, $argv); 22 | $this->configureServer($this->source, $this->sourceUrl, $this->sourceUsername, $this->sourcePassword); 23 | $this->configureServer($this->target, $this->targetUrl, $this->targetUsername, $this->targetPassword); 24 | $this->validate(); 25 | return $this; 26 | } 27 | 28 | protected function parseArgs(int $argc, array $argv) { 29 | for ($i = 1; $i < $argc; $i++) { 30 | if (in_array($argv[$i], ['--verbose', '-v'])) { 31 | $this->verbose = true; 32 | } else if (in_array($argv[$i], ['--dry-run', '--test', '-t'])) { 33 | $this->test = true; 34 | } else if (in_array($argv[$i], ['--listFolder'])) { 35 | $this->verbose = true; 36 | $this->test = true; 37 | $this->listFolder = true; 38 | } else if (in_array($argv[$i], ['--wipe', '-w'])) { 39 | $this->wipe = true; 40 | } else if (in_array($argv[$i], ['--source', '-s'])) { 41 | $i++; 42 | if (empty($argv[$i])) { 43 | echo 'You must specify a source IMAP server.' . PHP_EOL; 44 | exit(1); 45 | } 46 | $this->sourceUrl = $argv[$i]; 47 | } else if (in_array($argv[$i], ['--sourceUsername'])) { 48 | $i++; 49 | if (empty($argv[$i])) { 50 | echo 'You must specify a source username.' . PHP_EOL; 51 | exit(1); 52 | } 53 | $this->sourceUsername = $argv[$i]; 54 | } else if (in_array($argv[$i], ['--sourcePassword'])) { 55 | $i++; 56 | if (empty($argv[$i])) { 57 | echo 'You must specify a source password.' . PHP_EOL; 58 | exit(1); 59 | } 60 | $this->sourcePassword = $argv[$i]; 61 | } else if (in_array($argv[$i], ['--target', '-t'])) { 62 | $i++; 63 | if (empty($argv[$i])) { 64 | echo 'You must specify a target IMAP server.' . PHP_EOL; 65 | exit(1); 66 | } 67 | $this->targetUrl = $argv[$i]; 68 | } else if (in_array($argv[$i], ['--targetUsername'])) { 69 | $i++; 70 | if (empty($argv[$i])) { 71 | echo 'You must specify a target username.' . PHP_EOL; 72 | exit(1); 73 | } 74 | $this->targetUsername = $argv[$i]; 75 | } else if (in_array($argv[$i], ['--targetPassword'])) { 76 | $i++; 77 | if (empty($argv[$i])) { 78 | echo 'You must specify a target password.' . PHP_EOL; 79 | exit(1); 80 | } 81 | $this->targetPassword = $argv[$i]; 82 | } else if (in_array($argv[$i], ['--mapFolder'])) { 83 | $i++; 84 | if (empty($argv[$i])) { 85 | echo 'You must specify a map folder value.' . PHP_EOL; 86 | exit(1); 87 | } 88 | $data = json_decode($argv[$i], true); 89 | if (json_last_error()) { 90 | echo 'Folder mapping failed: ' . json_last_error_msg() . PHP_EOL; 91 | exit(1); 92 | } 93 | $this->mapFolder = $data; 94 | } else if (in_array($argv[$i], ['--memory', '-m'])) { 95 | $i++; 96 | if (empty($argv[$i])) { 97 | echo 'You must specify a memory value.' . PHP_EOL; 98 | exit(1); 99 | } 100 | $this->memory = $argv[$i]; 101 | } 102 | } 103 | } 104 | 105 | protected function configureServer(Server &$server, string $url, string $username, string $password) { 106 | $url = trim($url); 107 | $isJson = (strpos($url, '{') === 0); 108 | if ($isJson) { 109 | $jsonConfig = json_decode($url); 110 | if (json_last_error()) { 111 | echo 'Error converting json config: ' . json_last_error_msg() . PHP_EOL; 112 | exit(1); 113 | } 114 | $url = isset($jsonConfig->url) ? $jsonConfig->url : ''; 115 | } 116 | 117 | $uri = parse_url($url); 118 | if (isset($uri['scheme'])) { 119 | $server->setScheme($uri['scheme']); 120 | } 121 | if (isset($uri['host'])) { 122 | $server->setHost($uri['host']); 123 | } 124 | if (isset($uri['port'])) { 125 | $server->setPort($uri['port']); 126 | } 127 | if (isset($uri['user'])) { 128 | $server->setUsername($uri['user']); 129 | } 130 | if (isset($uri['pass'])) { 131 | $server->setPassword($uri['pass']); 132 | } 133 | if (isset($uri['path'])) { 134 | $server->setPath($uri['path']); 135 | } 136 | 137 | if ($isJson) { 138 | if (isset($jsonConfig->username)) { 139 | $server->setUsername($jsonConfig->username); 140 | } 141 | if (isset($jsonConfig->password)) { 142 | $server->setPassword($jsonConfig->password); 143 | } 144 | } 145 | 146 | if ($username !== '') { 147 | $server->setUsername($username); 148 | } 149 | if ($password !== '') { 150 | $server->setPassword($password); 151 | } 152 | } 153 | 154 | protected function validate() { 155 | $errors = []; 156 | if (!($this->source instanceof Server && $this->source->validate())) { 157 | $errors[] = 'You must specify a source IMAP server.'; 158 | } 159 | if (!($this->target instanceof Server && $this->target->validate())) { 160 | $errors[] = 'You must specify a target IMAP server.'; 161 | } 162 | if (count($errors)) { 163 | echo implode(PHP_EOL, $errors) . PHP_EOL; 164 | exit(1); 165 | } 166 | } 167 | 168 | public function isVerbose(): bool { 169 | return $this->verbose; 170 | } 171 | 172 | public function isTest(): bool { 173 | return $this->test; 174 | } 175 | 176 | public function isListFolder(): bool { 177 | return $this->listFolder; 178 | } 179 | 180 | public function isWipe(): bool { 181 | return $this->wipe; 182 | } 183 | 184 | public function getSource(): Server { 185 | return $this->source; 186 | } 187 | 188 | public function getTarget(): Server { 189 | return $this->target; 190 | } 191 | 192 | public function getMapFolder(): array { 193 | return $this->mapFolder; 194 | } 195 | 196 | public function getMemory(): string { 197 | return $this->memory; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/Entity/Message.php: -------------------------------------------------------------------------------- 1 | messageNumber; 21 | } 22 | 23 | public function setMessageNumber(int $messageNumber): self { 24 | $this->messageNumber = $messageNumber; 25 | return $this; 26 | } 27 | 28 | public function getMessageId(): string { 29 | return $this->messageId; 30 | } 31 | 32 | public function setMessageId(string $messageId): self { 33 | $this->messageId = $messageId; 34 | return $this; 35 | } 36 | 37 | public function getSubject(): string { 38 | return $this->subject; 39 | } 40 | 41 | public function setSubject(string $subject): self { 42 | $this->subject = $subject; 43 | return $this; 44 | } 45 | 46 | public function getMailDate(): string { 47 | return $this->mailDate; 48 | } 49 | 50 | public function setMailDate(string $mailDate): self { 51 | $this->mailDate = $mailDate; 52 | return $this; 53 | } 54 | 55 | public function getFlagUnseen(): string { 56 | return $this->flagUnseen; 57 | } 58 | 59 | public function setFlagUnseen(string $flagUnseen): self { 60 | $this->flagUnseen = $flagUnseen; 61 | return $this; 62 | } 63 | 64 | public function getFlagFlagged(): string { 65 | return $this->flagFlagged; 66 | } 67 | 68 | public function setFlagFlagged(string $flagFlagged): self { 69 | $this->flagFlagged = $flagFlagged; 70 | return $this; 71 | } 72 | 73 | public function getFlagAnswered(): string { 74 | return $this->flagAnswered; 75 | } 76 | 77 | public function setFlagAnswered(string $flagAnswered): self { 78 | $this->flagAnswered = $flagAnswered; 79 | return $this; 80 | } 81 | 82 | public function getFlagDeleted(): string { 83 | return $this->flagDeleted; 84 | } 85 | 86 | public function setFlagDeleted(string $flagDeleted): self { 87 | $this->flagDeleted = $flagDeleted; 88 | return $this; 89 | } 90 | 91 | public function getFlagDraft(): string { 92 | return $this->flagDraft; 93 | } 94 | 95 | public function setFlagDraft(string $flagDraft): self { 96 | $this->flagDraft = $flagDraft; 97 | return $this; 98 | } 99 | 100 | public function getHash(): string { 101 | return $this->hash; 102 | } 103 | 104 | public function updateHash(): self { 105 | $this->hash = md5(serialize([ 106 | 'Unseen' => $this->flagUnseen, 107 | 'Flagged' => $this->flagFlagged, 108 | 'Answered' => $this->flagAnswered, 109 | 'Deleted' => $this->flagDeleted, 110 | 'Draft' => $this->flagDraft, 111 | ])); 112 | return $this; 113 | } 114 | 115 | public function getMailOptions(): string { 116 | $mailOptions = []; 117 | foreach (['Unseen', 'Flagged', 'Answered', 'Deleted', 'Draft'] as $flag) { 118 | $flagName = 'flag' . $flag; 119 | if ($flag === 'Unseen' && empty($this->$flagName)) { 120 | $mailOptions[] = '\\Seen'; 121 | } else if ($flag !== 'Unseen' && !empty($this->$flagName)) { 122 | $mailOptions[] = '\\' . $flag; 123 | } 124 | } 125 | return implode(' ', $mailOptions); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/ImapUtility.php: -------------------------------------------------------------------------------- 1 | server = $server; 11 | $this->verbose = $isVerbose; 12 | $this->test = $isTest; 13 | $this->connect(); 14 | } 15 | 16 | protected function connect() { 17 | if ($this->verbose) { 18 | echo 'Connect to: ' . $this->server->getImapServerPart() . PHP_EOL; 19 | } 20 | 21 | $this->connection = imap_open($this->server->getImapServerPart(), $this->server->getUsername(), $this->server->getPassword()); 22 | $this->checkAndThrowImapError('Could\'t connect to host:'); 23 | } 24 | 25 | protected function checkAndThrowImapError(string $message = 'IMAP Error:') { 26 | $imapErrors = imap_errors(); 27 | if (!empty($imapErrors)) { 28 | echo $message . PHP_EOL . implode(PHP_EOL, $imapErrors) . PHP_EOL; 29 | exit(1); 30 | } 31 | } 32 | 33 | /** 34 | * List folders matching pattern 35 | * @param $pattern * == all folders, % == folders at current level 36 | */ 37 | public function getFolders(string $pattern = '*'): array { 38 | return imap_getmailboxes($this->connection, $this->server->getImapServerPart(), $pattern); 39 | } 40 | 41 | public function changeFolder(string $path, bool $createFolder = false, string $key = ''): bool { 42 | if (substr($path, 0, 1) !== '{') { 43 | $path = $this->server->getImapServerPart() . trim($path, '/'); 44 | } 45 | 46 | if ($this->verbose) { 47 | echo 'Change ' . (!empty($key) ? $key . ' ' : '') . 'path: ' . $this->getNameFromPath($path) . PHP_EOL; 48 | } 49 | imap_reopen($this->connection, $path); 50 | $imapErrors = imap_errors(); 51 | if (empty($imapErrors)) { 52 | $this->updateStats($path); 53 | return true; 54 | } 55 | 56 | // Couldn't open Mailbox folder, so create it 57 | if ($createFolder && preg_match('/(NONEXISTENT|Mailbox doesn\'t exist)/i', implode(', ', $imapErrors))) { 58 | $this->createFolder($path); 59 | imap_reopen($this->connection, $path); 60 | if (empty(imap_errors())) { 61 | $this->updateStats($path); 62 | return true; 63 | } 64 | } 65 | 66 | $this->checkAndThrowImapError('Failed to Switch change path (' . $path . '):'); 67 | return false; 68 | } 69 | 70 | protected function createFolder(string $path) { 71 | if (substr($path, 0, 1) !== '{') { 72 | $path = $this->server->getImapServerPart() . trim($path, '/'); 73 | } 74 | 75 | if ($this->verbose) { 76 | echo 'Create folder: ' . $this->getNameFromPath($path) . PHP_EOL; 77 | } 78 | if ($this->test) { 79 | return; 80 | } 81 | imap_createmailbox($this->connection, $path); 82 | $this->checkAndThrowImapError('Failed to create folder (' . $path . '):'); 83 | } 84 | 85 | public function getStats(): \stdClass { 86 | if (!$this->stats) { 87 | $this->stats = new \stdClass(); 88 | $this->stats->count = 0; 89 | $this->stats->mailbox = ''; 90 | } 91 | return $this->stats; 92 | } 93 | 94 | protected function updateStats($path = null) { 95 | if (substr($path, 0, 1) !== '{') { 96 | $path = $this->server->getImapServerPart() . trim($path, '/'); 97 | } 98 | 99 | $this->stats = new \stdClass(); 100 | $status = imap_status($this->connection, $path, SA_MESSAGES); 101 | $this->stats->count = $status->messages; 102 | 103 | $check = imap_check($this->connection); 104 | $this->stats->mailbox = $check->Mailbox; 105 | } 106 | 107 | public function getMessage($messageNumber): string { 108 | return imap_fetchbody($this->connection, $messageNumber, null, FT_PEEK); 109 | } 110 | 111 | public function putMessage($mail, $opts, $date) { 112 | if ($this->test) { 113 | return true; 114 | } 115 | $return = imap_append($this->connection, $this->stats->mailbox, $mail, $opts, $date); 116 | $this->checkAndThrowImapError('Failed put message:'); 117 | return $return; 118 | } 119 | 120 | public function removeMessages(array $messageNumbers) { 121 | if ($this->test) { 122 | return; 123 | } 124 | if (empty($messageNumbers)) { 125 | return; 126 | } 127 | foreach ($messageNumbers as $messageNumber) { 128 | if (!imap_delete($this->connection, $messageNumber)) { 129 | echo 'Can\'t remove message ' . $messageNumber . '!' . PHP_EOL; 130 | exit(1); 131 | } 132 | } 133 | if (!imap_expunge($this->connection)) { 134 | echo 'Can\'t expunge messages!' . PHP_EOL; 135 | exit(1); 136 | } 137 | } 138 | 139 | public function isPathExcluded(stdClass $folder): bool { 140 | if (($folder->attributes & LATT_NOSELECT) == LATT_NOSELECT) { 141 | return true; 142 | } 143 | 144 | // All Mail, Trash, Starred have this attribute 145 | if (($folder->attributes & 96) == 96) { 146 | return true; 147 | } 148 | 149 | // Skip by Pattern 150 | if (preg_match('/}(.+)$/', $folder->name, $matches)) { 151 | switch (strtolower($matches[1])) { 152 | case '[gmail]/all mail': 153 | case '[gmail]/sent mail': 154 | case '[gmail]/spam': 155 | case '[gmail]/starred': 156 | return true; 157 | } 158 | } 159 | 160 | // By First Folder Part of Name 161 | if (preg_match('/}([^\/]+)/', $folder->name, $matches)) { 162 | switch (strtolower($matches[1])) { 163 | // This bundle is from Exchange 164 | case 'journal': 165 | case 'notes': 166 | case 'outbox': 167 | case 'rss feeds': 168 | case 'sync issues': 169 | return true; 170 | } 171 | } 172 | 173 | return false; 174 | } 175 | 176 | public function mapPath($path) { 177 | if (preg_match('/}(.+)$/', $path, $matches)) { 178 | switch (strtolower($matches[1])) { 179 | // case 'inbox': return null; 180 | case 'deleted items': return '[Gmail]/Trash'; 181 | case 'drafts': return '[Gmail]/Drafts'; 182 | case 'junk e-mail': return '[Gmail]/Spam'; 183 | case 'sent items': return '[Gmail]/Sent Mail'; 184 | } 185 | $path = str_replace('INBOX/', null, $matches[1]); 186 | } 187 | return $path; 188 | } 189 | 190 | public function getNameFromPath($path) { 191 | $name = ''; 192 | preg_match('/}(.+)$/', $path, $matches); 193 | if (count($matches) > 0) { 194 | $name = str_replace('INBOX/', null, $matches[1]); 195 | } 196 | return $name; 197 | } 198 | 199 | public function getMessages(string $outputText = 'Indexing messages:'): array { 200 | if ($this->verbose) { 201 | echo $outputText . PHP_EOL; 202 | } 203 | 204 | $messages = []; 205 | $length = strlen($this->getStats()->count); 206 | for ($messageNumber = 1; $messageNumber <= $this->getStats()->count; $messageNumber++) { 207 | $mailHeader = imap_headerinfo($this->connection, $messageNumber); 208 | 209 | $message = new \App\Entity\Message(); 210 | $message->setMessageNumber($messageNumber) 211 | ->setMessageId(isset($mailHeader->message_id) ? $mailHeader->message_id : '') 212 | ->setSubject(isset($mailHeader->subject) ? $mailHeader->subject : '') 213 | ->setMailDate($mailHeader->MailDate) 214 | ->setFlagUnseen(trim($mailHeader->Unseen)) 215 | ->setFlagFlagged(trim($mailHeader->Flagged)) 216 | ->setFlagAnswered(trim($mailHeader->Answered)) 217 | ->setFlagDeleted(trim($mailHeader->Deleted)) 218 | ->setFlagDraft(trim($mailHeader->Draft)) 219 | ->updateHash(); 220 | 221 | $messages[$messageNumber] = $message; 222 | 223 | if ($this->verbose) { 224 | echo '-> ' . str_pad($messageNumber, $length, ' ', STR_PAD_LEFT) . ': ' . $this->decodeSubject($message->getSubject()) . PHP_EOL; 225 | } 226 | } 227 | if ($this->verbose) { 228 | echo (count($messages) > 0 ? '' : '-> No messages found' . PHP_EOL) . PHP_EOL; 229 | } 230 | return $messages; 231 | } 232 | 233 | public function removeMessagesNotFound(array $sourceMessages, array $targetMessages): int { 234 | if ($this->verbose) { 235 | echo 'Remove messages on target server, which not exists on source server:' . PHP_EOL; 236 | } 237 | 238 | $mapHash = []; 239 | /** @var \App\Entity\Message $sourceMessage */ 240 | foreach ($sourceMessages as $sourceMessage) { 241 | if (!empty($sourceMessage->getMessageId())) { 242 | $mapHash[$sourceMessage->getMessageId()] = $sourceMessage->getMessageNumber(); 243 | } 244 | } 245 | 246 | $removeMessages = []; 247 | $length = strlen($this->getStats()->count); 248 | /** @var \App\Entity\Message $targetMessage */ 249 | foreach ($targetMessages as $targetMessage) { 250 | if (!empty($targetMessage->getMessageId()) && !array_key_exists($targetMessage->getMessageId(), $mapHash)) { 251 | $removeMessages[] = $targetMessage->getMessageNumber(); 252 | if ($this->verbose) { 253 | echo '-> ' . str_pad($targetMessage->getMessageNumber(), $length, ' ', STR_PAD_LEFT) . ': ' . $this->decodeSubject($targetMessage->getSubject()) . PHP_EOL; 254 | } 255 | } 256 | } 257 | if (!$this->test) { 258 | $this->removeMessages($removeMessages); 259 | } 260 | 261 | if ($this->verbose) { 262 | echo (count($removeMessages) > 0 ? '' : '-> No messages removed' . PHP_EOL) . PHP_EOL; 263 | } 264 | return count($removeMessages); 265 | } 266 | 267 | public function removeMessagesAll(array $messages): int { 268 | if ($this->verbose) { 269 | echo 'Remove all messages on target server:' . PHP_EOL; 270 | } 271 | 272 | $removeMessages = []; 273 | $length = strlen($this->getStats()->count); 274 | /** @var \App\Entity\Message $message */ 275 | foreach ($messages as $message) { 276 | $removeMessages[] = $message->getMessageNumber(); 277 | if ($this->verbose) { 278 | echo '-> ' . str_pad($message->getMessageNumber(), $length, ' ', STR_PAD_LEFT) . ': ' . $this->decodeSubject($message->getSubject()) . PHP_EOL; 279 | } 280 | } 281 | if (!$this->test) { 282 | $this->removeMessages($removeMessages); 283 | } 284 | 285 | if ($this->verbose) { 286 | echo (count($removeMessages) > 0 ? '' : '-> No messages removed' . PHP_EOL) . PHP_EOL; 287 | } 288 | return count($removeMessages); 289 | } 290 | 291 | public function decodeSubject(string $value): string { 292 | $subject = ''; 293 | foreach (imap_mime_header_decode($value) as $item) { 294 | $subject .= $item->text; 295 | } 296 | return $subject; 297 | } 298 | 299 | /* @todo Maybe good for path mapping in Google; check domain [gmail.com|googlemail.com] or --map-google-source --map-google-target 300 | function _path_map($x) { 301 | if (preg_match('/}(.+)$/', $x, $m)) { 302 | switch (strtolower($m[1])) { 303 | // case 'inbox': return null; 304 | case 'deleted items': return '[Gmail]/Trash'; 305 | case 'drafts': return '[Gmail]/Drafts'; 306 | case 'junk e-mail': return '[Gmail]/Spam'; 307 | case 'sent items': return '[Gmail]/Sent Mail'; 308 | } 309 | $x = str_replace('INBOX/', null, $m[1]); 310 | } 311 | return $x; 312 | }*/ 313 | } 314 | -------------------------------------------------------------------------------- /src/Server.php: -------------------------------------------------------------------------------- 1 | scheme; 13 | } 14 | 15 | public function setScheme(string $scheme): self { 16 | $this->scheme = $scheme; 17 | $this->updateImapServerPart(); 18 | return $this; 19 | } 20 | 21 | public function getHost(): string { 22 | return $this->host; 23 | } 24 | 25 | public function setHost(string $host): self { 26 | $this->host = $host; 27 | $this->updateImapServerPart(); 28 | return $this; 29 | } 30 | 31 | public function getPort(): int { 32 | return $this->port; 33 | } 34 | 35 | public function setPort(int $port): self { 36 | $this->port = $port; 37 | $this->updateImapServerPart(); 38 | return $this; 39 | } 40 | 41 | public function getUsername(): string { 42 | return $this->username; 43 | } 44 | 45 | public function setUsername(string $username): self { 46 | $this->username = $username; 47 | return $this; 48 | } 49 | 50 | public function getPassword(): string { 51 | return $this->password; 52 | } 53 | 54 | public function setPassword(string $password): self { 55 | $this->password = $password; 56 | return $this; 57 | } 58 | 59 | public function getPath(): string { 60 | return $this->path; 61 | } 62 | 63 | public function setPath(string $path): self { 64 | $path = ltrim($path,'/'); 65 | if ($path === '') { 66 | $path = 'INBOX'; 67 | } 68 | $this->path = $path; 69 | return $this; 70 | } 71 | 72 | public function getImapServerPart(): string { 73 | return $this->imapServerPart; 74 | } 75 | 76 | protected function updateImapServerPart(): self { 77 | $serverPart = '{' . $this->host; 78 | if ($this->port > 0) { 79 | $serverPart .= ':' . $this->port; 80 | } 81 | if (!empty($this->scheme)) { 82 | switch (strtolower($this->scheme)) { 83 | case 'imap-ssl': 84 | $serverPart .= '/ssl'; 85 | break; 86 | case 'imap-ssl-novalidate': 87 | $serverPart .= '/ssl/novalidate-cert'; 88 | break; 89 | case 'imap-tls': 90 | $serverPart .= '/tls'; 91 | break; 92 | default: 93 | } 94 | } 95 | $serverPart .= '}'; 96 | $this->imapServerPart = $serverPart; 97 | return $this; 98 | } 99 | 100 | public function validate() { 101 | return ($this->scheme !== '' && $this->host !== '' && $this->username !== '' && $this->password !== '' && $this->path !== ''); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/phpimapsync.php: -------------------------------------------------------------------------------- 1 | config = new Configuration($argc, $argv); 18 | error_reporting(E_ALL | E_STRICT); 19 | $this->renderHeader($header); 20 | $this->checkCliMode(); 21 | $this->setMemoryLimit($this->config->getMemory()); 22 | 23 | echo 'Connecting Source...' . PHP_EOL; 24 | $this->imapSource = new ImapUtility($this->config->getSource(), $this->config->isVerbose(), $this->config->isTest()); 25 | // @todo This synchronize everything, server->path with subfolder is useless 26 | $this->imapSourceFolders = $this->imapSource->getFolders(); 27 | $this->renderImapFolder($this->imapSource, $this->imapSourceFolders); 28 | 29 | echo 'Connecting Target...' . PHP_EOL; 30 | $this->imapTarget = new ImapUtility($this->config->getTarget(), $this->config->isVerbose(), $this->config->isTest()); 31 | // @todo This synchronize everything, server->path with subfolder is useless 32 | $this->imapTargetFolders = $this->imapTarget->getFolders(); 33 | $this->renderImapFolder($this->imapTarget, $this->imapTargetFolders); 34 | 35 | $this->renderMapFolderInfo(); 36 | 37 | if ($this->config->isListFolder()) { 38 | exit(0); 39 | } 40 | $this->sync(); 41 | } 42 | 43 | protected function renderHeader(string $header) { 44 | $headerHash = str_repeat('#', (int)(80 - strlen($header) - 2) / 2); 45 | echo $headerHash . ' ' . $header . ' ' . $headerHash . PHP_EOL; 46 | } 47 | 48 | protected function checkCliMode() { 49 | if (PHP_SAPI !== 'cli') { 50 | echo 'This script needs to be ran in CLI mode.' . PHP_EOL; 51 | exit(1); 52 | } 53 | } 54 | 55 | public function setMemoryLimit(string $memory = '') { 56 | if ($memory !== '') { 57 | ini_set('memory_limit', $memory); 58 | } 59 | } 60 | 61 | public function renderImapFolder(ImapUtility $imap, array $folders) { 62 | if ($this->config->isVerbose()) { 63 | foreach ($folders as $folder) { 64 | echo '-> ' . $imap->getNameFromPath($folder->name) . PHP_EOL; 65 | } 66 | echo PHP_EOL; 67 | } 68 | } 69 | 70 | protected function renderMapFolderInfo() { 71 | $mapFolder = $this->config->getMapFolder(); 72 | if (!empty($mapFolder) && $this->config->isVerbose()) { 73 | echo 'Mapping source to target paths:' . PHP_EOL; 74 | foreach ($this->imapSourceFolders as $folder) { 75 | $sourceFolder = $this->imapSource->getNameFromPath($folder->name); 76 | if (isset($mapFolder[$sourceFolder])) { 77 | echo '-> ' . $sourceFolder . ' => ' . $mapFolder[$sourceFolder] . PHP_EOL; 78 | } 79 | } 80 | echo PHP_EOL; 81 | } 82 | } 83 | 84 | public function isMessageIdentical(\App\Entity\Message $sourceMessage, array $targetMessages): bool { 85 | /** @var \App\Entity\Message $targetMessage */ 86 | foreach ($targetMessages as $targetMessage) { 87 | if (!empty($targetMessage->getMessageId()) && $targetMessage->getMessageId() === $sourceMessage->getMessageId()) { 88 | return ($sourceMessage === $targetMessage); 89 | } 90 | } 91 | return false; 92 | } 93 | 94 | public function sync() { 95 | $summary = new \stdClass(); 96 | $summary->Copied = 0; 97 | $summary->Updated = 0; 98 | $summary->Removed = 0; 99 | $summary->Exists = 0; 100 | $summary->Error = 0; 101 | 102 | echo 'Synchronize...' . PHP_EOL . PHP_EOL; 103 | $mapFolder = $this->config->getMapFolder(); 104 | if (is_array($this->imapSourceFolders)) { 105 | foreach ($this->imapSourceFolders as $imapSourceFolder) { 106 | if ($this->config->isVerbose()) { 107 | echo str_repeat('#', 80) . PHP_EOL; 108 | } 109 | echo '# Folder: ' . $this->imapSource->getNameFromPath($imapSourceFolder->name) . PHP_EOL; 110 | 111 | if ($this->imapSource->isPathExcluded($imapSourceFolder)) { 112 | echo '[Source] Skip, folder excluded: ' . $imapSourceFolder->name . PHP_EOL; 113 | continue; 114 | } 115 | 116 | if (!$this->imapSource->changeFolder($imapSourceFolder->name, false, 'source')) { 117 | echo 'Can\'t change source folder: ' . $this->imapSource->getNameFromPath($imapSourceFolder->name) . PHP_EOL; 118 | exit(1); 119 | } 120 | 121 | $targetFolderPath = $this->imapSource->getNameFromPath($imapSourceFolder->name); 122 | if ($targetFolderPath === '') { 123 | echo '[WARNING] Skip, target path empty:' . $imapSourceFolder->name . PHP_EOL; 124 | continue; 125 | } 126 | if (isset($mapFolder[$targetFolderPath])) { 127 | $targetFolderPath = $mapFolder[$targetFolderPath]; 128 | echo 'Mapping folder ' . $this->imapSource->getNameFromPath($imapSourceFolder->name) . ' to ' . $targetFolderPath . PHP_EOL; 129 | } 130 | 131 | $targetChangeFolderResult = $this->imapTarget->changeFolder($targetFolderPath, true, 'target'); 132 | if ($this->config->isTest() && !$targetChangeFolderResult) { 133 | echo 'Can\'t change target folder: ' . $targetFolderPath . ' (Skipped in testing mode)' . PHP_EOL; 134 | continue; 135 | } else if (!$targetChangeFolderResult) { 136 | echo 'Can\'t change target folder: ' . $targetFolderPath . PHP_EOL; 137 | exit(1); 138 | } 139 | 140 | echo 'Source: ' . $this->imapSource->getStats()->count .' messages | ' 141 | .'Target: ' . $this->imapTarget->getStats()->count .' messages' 142 | . PHP_EOL . ($this->config->isVerbose() ? PHP_EOL : ''); 143 | 144 | $sourceMessages = $this->imapSource->getMessages('Indexing source messages:'); 145 | $targetMessages = $this->imapTarget->getMessages('Indexing target messages:'); 146 | if ($this->config->isWipe()) { 147 | $summary->Removed += $this->imapTarget->removeMessagesAll($targetMessages); 148 | } else { 149 | $summary->Removed += $this->imapTarget->removeMessagesNotFound($sourceMessages, $targetMessages); 150 | } 151 | 152 | if ($this->config->isVerbose()) { 153 | echo 'Synchronize messages:' . PHP_EOL; 154 | } 155 | /** @var \App\Entity\Message $sourceMessage */ 156 | foreach ($sourceMessages as $sourceMessage) { 157 | $textNumber = str_pad($sourceMessage->getMessageNumber(), strlen($this->imapSource->getStats()->count), ' ', STR_PAD_LEFT); 158 | $updated = false; 159 | 160 | if (empty($sourceMessage->getSubject())) { 161 | $sourceMessage->setSubject('*** No Subject ***'); 162 | } 163 | 164 | $existsTargetMail = (!empty($sourceMessage->getMessageId()) ? array_key_exists($sourceMessage->getMessageId(), $targetMessages) : false); 165 | $textSubject = $this->imapSource->decodeSubject($sourceMessage->getSubject()); 166 | 167 | $isMessageIdentical = false; 168 | if (!empty($sourceMessage->getMessageId())) { 169 | $isMessageIdentical = $this->isMessageIdentical($sourceMessage, $targetMessages); 170 | } 171 | 172 | if ($existsTargetMail && $isMessageIdentical) { 173 | // Message already exists and has not changed 174 | if ($this->config->isVerbose()) { 175 | echo '-> ' . $textNumber . ': [Exists] ' . $textSubject . PHP_EOL; 176 | } 177 | $summary->Exists++; 178 | } else { 179 | if ($existsTargetMail && !$isMessageIdentical) { 180 | $this->imapTarget->removeMessages([$sourceMessage->getMessageNumber()]); 181 | $updated = true; 182 | } 183 | 184 | $mailOptions = $sourceMessage->getMailOptions(); 185 | $date = strftime('%d-%b-%Y %H:%M:%S +0000', strtotime($sourceMessage->getMailDate())); 186 | $result = $this->imapTarget->putMessage($this->imapSource->getMessage($sourceMessage->getMessageNumber()), $mailOptions, $date); 187 | $textOptions = str_replace('\\', '', $mailOptions); 188 | if ($result) { 189 | if ($this->config->isVerbose()) { 190 | echo '-> ' . $textNumber . ': [' . ($updated ? 'Update' : 'Copied') . '] ' . $textSubject . ' (' . $textOptions . ')' . PHP_EOL; 191 | } 192 | $updated ? $summary->Updated++ : $summary->Copied++; 193 | } else { 194 | if ($this->config->isVerbose()) { 195 | echo '-> ' . $textNumber . ': [Error] ' . $textSubject . '(' . $textOptions . ')' . PHP_EOL; 196 | } 197 | $summary->Error++; 198 | } 199 | } 200 | } 201 | echo PHP_EOL; 202 | } 203 | 204 | echo 'Summary:' . PHP_EOL; 205 | foreach ($summary as $key => $value) { 206 | echo '-> ' . $key . ': ' . $value . PHP_EOL; 207 | } 208 | } 209 | } 210 | } 211 | 212 | $imapSync = new ImapSync(); 213 | $imapSync->initialize($argc, $argv, 'PHP-IMAP-Sync'); 214 | exit(0); 215 | --------------------------------------------------------------------------------