├── LICENSE ├── README.md ├── base ├── config.php └── load.php ├── configs ├── main.php ├── mongo.php └── mysql.php ├── maillog-importer.php ├── maillogimporter ├── dateconvertor.php ├── fileimporter.php ├── maillogexception.php ├── parser.php └── writer │ ├── mongo.php │ ├── mysql.php │ └── writerinterface.php └── sql └── mysql_create_table.sql /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Iskren Slavov. All rights reserved. 2 | 3 | Developed by: Iskren Slavov 4 | https://github.com/zewish/ 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal with the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimers. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimers in the documentation and/or other materials provided with the distribution. 10 | 3. Neither the name of Iskren Slavov, nor the names of its contributors may be used to endorse or promote products derived from this Software without specific prior written permission. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Postfix Mallog Parser And Importer Written in PHP 2 | This is a simple command-line utility that when used will import the statuses of the Postfix messages queued on your email server. 3 | 4 | The application can be used to parse the lines of the mail.log file and import them into a database storage - the currently supported database storages are MongoDB or MySQL/MariaDB. 5 | 6 | ```bash 7 | # Example usage: 8 | php ./maillog-importer.php /var/log/mail.log 9 | ``` 10 | 11 | Please have a quick look at the files in the "configs" directory before using this tool. 12 | -------------------------------------------------------------------------------- /base/config.php: -------------------------------------------------------------------------------- 1 | _settings)) { 12 | return $this->_settings[$name]; 13 | } 14 | 15 | $method = '_get' . ucfirst($name); 16 | if (method_exists(__CLASS__, $method)) { 17 | return $this->$method; 18 | } 19 | 20 | throw new ConfigException('Config setting "' . $name . '" not found.'); 21 | } 22 | 23 | public function __set($name, $value) 24 | { 25 | $this->_settings[$name] = $value; 26 | } 27 | } 28 | 29 | class ConfigException extends \Exception {} -------------------------------------------------------------------------------- /base/load.php: -------------------------------------------------------------------------------- 1 | 'mysite.com', 16 | 17 | /** 18 | * Available writers: MySql/Mongo (Warning: CaSe-sENsitiVe) 19 | * 20 | * Corresponding configuration files can be found inside the 'configs' directory. 21 | * If you are using the 'MySql' writer be sure to create database and import the 22 | * table schema from the 'sql' directory. 23 | * 24 | * If using MariaDB for storage please use the 'MySql' writer. 25 | */ 26 | 'dbWriterClass' => 'Mongo', 27 | 28 | /** 29 | * The default filename to be parsed when the script is called withot any parameters. 30 | */ 31 | 'defaultMaillogFile' => '/var/log/mail.log', 32 | 33 | /** 34 | * The regular expression used to detect the date of the line in the Postfix 35 | * 'mail.log' file. It is unlikely that you need to change this unless you have 36 | * some really weird package of Postfix. 37 | */ 38 | 'dateTimeRegex' => '^([a-zA-Z]{3} [\d|\s]\d \d{2}\:\d{2}\:\d{2})', 39 | ); 40 | } -------------------------------------------------------------------------------- /configs/mongo.php: -------------------------------------------------------------------------------- 1 | 'mongodb://localhost:27017', 17 | 'dbName' => 'emailLogs', 18 | 'collectionName' => 'logs', 19 | ); 20 | } -------------------------------------------------------------------------------- /configs/mysql.php: -------------------------------------------------------------------------------- 1 | 'localhost', 17 | 'username' => 'root', 18 | 'password' => 'Str0ngP4$sw0rd!', 19 | 'dbName' => 'emailLogs', 20 | 'tableName' => 'email_log', 21 | 22 | /** 23 | * This is a very important option that depends on the configuration 24 | * of your MySQL/MariaDB server. This is the batch insert count when 25 | * parsing. '1000' must be pretty standard count, but if you 26 | * experience any problems you can tweak this eiter up or down. 27 | */ 28 | 'batchInsertsCount' => 1000, 29 | ); 30 | } -------------------------------------------------------------------------------- /maillog-importer.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | defaultMaillogFile; 12 | // Print some help or set some settings 13 | if (!empty($argv[1])) { 14 | if ($argv[1] == '--help' || $argv[1] == '-h') { 15 | println('Usage: php maillog-importer.php [path_to_maillog_file]'); 16 | println('This script is used to prase maillog data and write it to Db.'); 17 | exit(0); 18 | } 19 | elseif (is_file($argv[1]) && is_readable($argv[1])) { 20 | $maillogFile = $argv[1]; 21 | } 22 | else { 23 | println('Invalid usage, try --help'); 24 | exit(1); 25 | } 26 | } 27 | 28 | try { 29 | // Create database writer 30 | $dbWriterClass = 'MaillogImporter\\Writer\\' . Config('Main')->dbWriterClass; 31 | $dbWriter = new $dbWriterClass(); 32 | // Create file importer and import data via the database writer 33 | $fileImporter = new MaillogImporter\FileImporter($maillogFile); 34 | $fileImporter->import($dbWriter); 35 | // Just an informational message 36 | println('OK'); 37 | exit(0); 38 | } 39 | catch (\Exception $ex) { 40 | throw $ex; 41 | exit(1); 42 | } 43 | -------------------------------------------------------------------------------- /maillogimporter/dateconvertor.php: -------------------------------------------------------------------------------- 1 | _fileHandle = fopen($fileName, 'r'); 26 | if (empty($this->_fileHandle)) { 27 | throw new MaillogException('Could not open maillog file "' . $fileName . '" for reading'); 28 | } 29 | } 30 | 31 | public function import(\MaillogImporter\Writer\WriterInterface $writer) 32 | { 33 | // Load the last previously parsed date from the Db 34 | $startDate = $writer->getLastDate(); 35 | $startDateTimestamp = (!empty($startDate)) ? $startDate->getTimestamp() : 0; 36 | $dateTimeRegex = '/' . Config('Main')->dateTimeRegex . '/'; 37 | 38 | // Get all the data from the 'maillog' and add it to the database 39 | $lastParsedDate = null; 40 | $MaillogImporter = new \MaillogImporter\Parser($writer); 41 | while (($line = fgets($this->_fileHandle)) != false) { 42 | if (preg_match($dateTimeRegex, $line, $matches)) { 43 | $lastParsedDate = $matches[1]; 44 | // Skip the lines that are already logged 45 | if (!empty($startDateTimestamp) && (\MaillogImporter\DateConvertor::createFromPostfix($lastParsedDate)->getTimestamp() - $startDateTimestamp) <= 0) { 46 | continue; 47 | } 48 | $MaillogImporter->parseLine($line); 49 | } 50 | else { 51 | throw new MaillogException('Could not match line with the date regex: "' . $line . '"'); 52 | } 53 | } 54 | } 55 | 56 | public function __destruct() 57 | { 58 | // Close 'maillog' file 59 | if (!empty($this->_fileHandle)) { 60 | fclose($this->_fileHandle); 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /maillogimporter/maillogexception.php: -------------------------------------------------------------------------------- 1 | _writer = $writer; 19 | } 20 | 21 | public function parseLine($line) 22 | { 23 | if (!$this->_parseMessageIds($line)) { 24 | if (!$this->_parseSendStatuses($line)) { 25 | $this->_parseExpiredStatuses($line); 26 | } 27 | } 28 | } 29 | 30 | /** 31 | * Get message IDs: Postfix message ID and NC message ID, including date updated. 32 | * @param string $line 33 | * @return boolean 34 | */ 35 | protected function _parseMessageIds($line) 36 | { 37 | if (preg_match('/' . Config('Main')->dateTimeRegex . '(?:.*postfix\/cleanup\[\d+\]\: )([A-Z0-9]{11})(?:\: message-id=<)([a-zA-Z0-9]+)(?:@' 38 | . Config('Main')->messageIdHost . '>)/', $line, $matches)) { 39 | $postfixMessageId = $matches[2]; 40 | $this->_writer->addData($postfixMessageId, array( 41 | 'email_message_id' => $matches[3], 42 | 'date_updated' => \MaillogImporter\DateConvertor::createFromPostfix($matches[1]), 43 | 'status' => 'unknown', 44 | 'info' => '', 45 | )); 46 | return true; 47 | } 48 | return false; 49 | } 50 | 51 | /** 52 | * Get statuses: success/deferred/bounced 53 | * @param string $line 54 | * @return boolean 55 | */ 56 | protected function _parseSendStatuses($line) 57 | { 58 | if (preg_match('/' . Config('Main')->dateTimeRegex . '(?:.*postfix\/smtp\[\d+\]\: )([A-Z0-9]{11})(?:\: to=<.*>.*dsn=[\d\.]+, status=)([a-zA-Z]+)(?: \()(.*)(?:\))/', $line, $matches)) { 59 | $this->_updateData($matches); 60 | return true; 61 | } 62 | return false; 63 | } 64 | 65 | /** 66 | * Get statuses: expired (and probably something else from 'qmgr') 67 | * @param string $line 68 | * @return boolean 69 | */ 70 | protected function _parseExpiredStatuses($line) 71 | { 72 | if (preg_match('/' . Config('Main')->dateTimeRegex . '(?:.*postfix\/qmgr\[\d+\]\: )([A-Z0-9]{11})(?:\: from=<.*>, status=)([a-zA-Z]+)/', $line, $matches)) { 73 | $this->_updateData($matches); 74 | return true; 75 | } 76 | return false; 77 | } 78 | 79 | protected function _updateData(array $matches) 80 | { 81 | if (empty($matches[4])) { 82 | $matches[4] = null; 83 | } 84 | 85 | $postfixMessageId = $matches[2]; 86 | $this->_writer->updateData($postfixMessageId, array( 87 | 'date_updated' => \MaillogImporter\DateConvertor::createFromPostfix($matches[1]), 88 | 'status' => $matches[3], 89 | 'info' => $matches[4] 90 | )); 91 | } 92 | } -------------------------------------------------------------------------------- /maillogimporter/writer/mongo.php: -------------------------------------------------------------------------------- 1 | connectionUri); 21 | // Load the requested connection 22 | $collectionName = $mongoConfig->collectionName; 23 | $this->_db = $dbConnection->selectDB($mongoConfig->dbName)->$collectionName; 24 | // Ensure we have descending index on 'date_updated' column 25 | $this->_db->ensureIndex(array('date_updated' => -1)); 26 | } 27 | 28 | public function getLastDate() 29 | { 30 | // Get the last item 31 | $lastItem = $this->_db->find()->sort(array('date_updated' => -1))->limit(1)->getNext(); 32 | if (empty($lastItem) || empty($lastItem['date_updated'])) { 33 | return null; 34 | } 35 | // Extract 'date_updated' column only 36 | $dbDateTime = $lastItem['date_updated']; 37 | if (empty($dbDateTime['date']) || empty($dbDateTime['timezone'])) { 38 | return null; 39 | } 40 | // Convert 'date_updated' to DateTime 41 | return new \DateTime($dbDateTime['date'], new \DateTimeZone($dbDateTime['timezone'])); 42 | } 43 | 44 | public function addData($postfixMessageId, array $data) 45 | { 46 | return $this->updateData($postfixMessageId, $data); 47 | } 48 | 49 | public function updateData($postfixMessageId, array $data) 50 | { 51 | return $this->_db->update( 52 | array('_id' => $postfixMessageId), 53 | array('$set' => $data), 54 | array('upsert' => true) 55 | ); 56 | } 57 | } -------------------------------------------------------------------------------- /maillogimporter/writer/mysql.php: -------------------------------------------------------------------------------- 1 | _db = new \PDO( 32 | "mysql:host=" . $mysqlConfig->hostname . ';dbname=' . $mysqlConfig->dbName . ';charset=utf8', 33 | $mysqlConfig->username, $mysqlConfig->password, array(\PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES 'utf8';") 34 | ); 35 | 36 | $this->_tableName = $mysqlConfig->tableName; 37 | $this->_batchInsertsCount = $mysqlConfig->batchInsertsCount; 38 | } 39 | 40 | public function __destruct() 41 | { 42 | $this->_flushInserts(); 43 | } 44 | 45 | public function getLastDate() 46 | { 47 | $sql = 'SELECT `date_updated` FROM `' . $this->_tableName . '` ORDER BY `date_updated` DESC LIMIT 0,1'; 48 | $dbStatement = $this->_db->prepare($sql); 49 | if (!$dbStatement->execute()) { 50 | throw new \MaillogImporter\MaillogException('Could not execute the following SQL statement: ' . $sql); 51 | } 52 | // Get the last added row 53 | $row = $dbStatement->fetch(\PDO::FETCH_ASSOC); 54 | if (empty($row) || empty($row['date_updated'])) { 55 | return null; 56 | } 57 | return \DateTime::createFromFormat(self::DATETIME_FORMAT_MYSQL, $row['date_updated']);; 58 | } 59 | 60 | public function addData($postfixMessageId, array $data) 61 | { 62 | $this->_inserts[$postfixMessageId] = $data; 63 | if (count($this->_inserts) >= $this->_batchInsertsCount) { 64 | $this->_flushInserts(); 65 | } 66 | } 67 | 68 | public function updateData($postfixMessageId, array $data) 69 | { 70 | if (array_key_exists($postfixMessageId, $this->_inserts)) { 71 | if (array_key_exists('info', $this->_inserts[$postfixMessageId]) && !empty($data['info'])) { 72 | $oldInfo = $this->_inserts[$postfixMessageId]['info']; 73 | $data['info'] = $oldInfo . (!empty($oldInfo) ? "\n" : '') . $data['info']; 74 | } 75 | $this->_inserts[$postfixMessageId] = array_replace($this->_inserts[$postfixMessageId], $data); 76 | } 77 | else { 78 | $this->_updateEmailData($postfixMessageId, $data['date_updated'], $data['status'], $data['info']); 79 | } 80 | } 81 | 82 | protected function _updateEmailData($postfixMessageId, $dateUpdated, $status, $info) 83 | { 84 | $sql = 'UPDATE `' . $this->_tableName . '` SET ' 85 | . '`date_updated` = ' . $this->_db->quote($dateUpdated) . ', `status` = ' . $this->_db->quote($status); 86 | // Update 'info' only when there's something to add 87 | if (!empty($info)) { 88 | $info = $this->_db->quote($info); 89 | $sql .= ', `info` = IF(`info` = "", ' . $info . ' , CONCAT(`info`, "\n", ' . $info . '))'; 90 | } 91 | $sql .= ' WHERE `postfix_message_id` = ' . $this->_db->quote($postfixMessageId); 92 | 93 | $dbStatement = $this->_db->prepare($sql); 94 | if (!$dbStatement->execute()) { 95 | throw new \MaillogImporter\MaillogException('Could not execute UPDATE database statement: "' . $sql . '"'); 96 | } 97 | } 98 | 99 | protected function _flushInserts() 100 | { 101 | // Don't do anything if there's nothing to insert 102 | if (empty($this->_inserts)) { 103 | return; 104 | } 105 | // Generate SQL statement 106 | $sql = 'INSERT INTO `' . $this->_tableName . '` ' 107 | . '(`email_message_id`, `postfix_message_id`, `date_updated`, `status`, `info`) VALUES' . "\r\n"; 108 | $insertsCount = count($this->_inserts); 109 | $i = 0; 110 | foreach ($this->_inserts as $postfixMessageId => $insertData) { 111 | // Generate values 112 | $sql .= '(' 113 | . $this->_db->quote($insertData['email_message_id']) . ', ' 114 | . $this->_db->quote($postfixMessageId) . ', ' 115 | . $this->_db->quote($insertData['date_updated']->format(self::DATETIME_FORMAT_MYSQL)) . ', ' 116 | . $this->_db->quote($insertData['status']) . ', ' 117 | . $this->_db->quote($insertData['info']) 118 | . ')'; 119 | // Add commas only when needed 120 | $sql .= (++$i == $insertsCount) ? "\r\n" : ",\r\n"; 121 | } 122 | // If key already exists do an UPDATE 123 | $sql .= 124 | ' ON DUPLICATE KEY UPDATE ' 125 | . '`email_message_id` = VALUES(`email_message_id`), ' 126 | . '`postfix_message_id` = VALUES(`postfix_message_id`), ' 127 | . '`date_updated` = VALUES(`date_updated`), ' 128 | . '`status` = VALUES(`status`), ' 129 | . '`info` = VALUES(`info`)'; 130 | 131 | // Execute INSERT statement and cleanup 'inserts' array 132 | $dbStatement = $this->_db->prepare($sql); 133 | if (!$dbStatement->execute()) { 134 | throw new \MaillogImporter\MaillogException('Could not execute INSERT database statement: "' . $sql . '"'); 135 | } 136 | $this->_inserts = array(); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /maillogimporter/writer/writerinterface.php: -------------------------------------------------------------------------------- 1 |