Предполагается, что был установлен POSTFIX и включен SMTP (для входящей почты нужны только MX-запись, SMTP и TLS, остальное настраивать необязательно).
Чтобы письма приходили на сервер, нужно чтобы на него ссылалась MX-запись. Проверяем, настроена ли MX-запись:
host -t mx $(hostname --fqdn)
Если записи нет, нужно зайти в панель управления доменом и добавить её.
Все команды необходимо выполнять в консоли от суперпользователя.
Редактируем конфиги POSTFIX'а, чтобы принимать письма и передавать внешнему обработчику:
sed -i -E '/^#?smtp .*smtpd.*/{s/#?(.*)/\1/;a\ -o content_filter=myhook:dummy
}' /etc/postfix/master.cf
cat >> /etc/postfix/master.cf <<EOF
## Обработка входящей почты
myhook unix - n n - - pipe
flags=F user=www-data argv=/var/www/mail/save.php \${sender} \${recipient}
EOF
cat >> /etc/postfix/main.cf <<EOF
# Карты алиасов
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
# Разрешаем приём писем для всех
local_recipient_maps =
# Пересылаем все письма руту
luser_relay = root@\$mydomain
EOF
newaliases
service postfix restart
После этого все письма будут передаваться в стандартный поток скрипту /var/www/mail/save.php
. Задача скрипта обработать
письмо и ничего не вывести в стандартный поток, чтобы письмо дальше не пошло.
Хранить письма будем в базе данных mail
, а загружать через PHP.
Устанавливаем необходимые пакеты:
# База данных MariaDB
apt install mariadb-server
# Пакеты PHP
apt install php-cli php-mysql php-mbstring php-imap php-mailparse
Предполагается, что установится версия PHP не ниже 7.3 (стандартная версия для Debian 10).
Создаём базу данных и пользователя:
mysql <<SQL
-- База данных
CREATE DATABASE IF NOT EXISTS mail;
-- Пользователь test с паролем test для базы mail
GRANT ALL PRIVILEGES ON mail.* TO test@localhost IDENTIFIED BY 'test';
SQL
Добавляем таблицы:
mysql mail <<SQL
-- Метаданные входящих писем
CREATE TABLE IF NOT EXISTS mail_inbox (
id int(1) unsigned NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT 'Идентификатор записи',
meta json NOT NULL COMMENT 'Метаданные входящих писем в формате JSON',
creation_time timestamp NOT NULL DEFAULT current_timestamp() COMMENT 'Время создания записи'
) DEFAULT CHARSET=utf8mb4 COMMENT='Входящие письма (метаданные)';
-- Сами письма
CREATE TABLE mail_inbox_data (
id int(1) unsigned NOT NULL PRIMARY KEY COMMENT 'Идентификатор записи',
data mediumblob NOT NULL COMMENT 'Данные хранилища'
) DEFAULT CHARSET=utf8mb4 COMMENT='Входящие письма (данные)';
SQL
В таблице mail_inbox
будут храниться общие данные о письме (кто, кому, когда и т.д.), а само письмо будет в таблице
mail_inbox_data
.
Создайте папку для хранения файлов-обработчиков:
mkdir /var/www/mail
Создайте в папке следующие файлы:
Файл .init.php
<?php
function Db() : PDO {
static $db = null;
if (is_null($db)) {
$db = new PDO('mysql:dbname=mail;host=localhost', 'test', 'test', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
}
return $db;
}
function Db_quote_name($name) : string {
return '`' . $name . '`';
}
function Db_quote_names($names) : array {
return array_map('Db_quote_name', (array) $names);
}
function Db_save(string $table, array $fields) {
$keys = array_keys($fields);
$values = array_fill(0, count($keys), '?');
$sql = 'INSERT INTO ' . Db_quote_name($table) . ' ( ' . join(', ', Db_quote_names($keys)) .' ) VALUES (' . join(', ', $values) . ')';
Db()->prepare($sql)->execute(array_values($fields));
return Db()->lastInsertId();
}
function Db_select_value(string $table, string $field, string $where) {
$sql = 'SELECT ' . Db_quote_name($field) . ' FROM ' . Db_quote_name($table) .' WHERE ' . $where . ' LIMIT 1';
return Db()->query($sql)->fetch()[0] ?? null;
}
Файл save.php
#!/usr/bin/env php
<?php
ob_start();
include '.init.php';
$smtpSender = $argv[1] ?? null; // Отправитель, который был указан при передаче письма
$smtpRecipient = $argv[2] ?? null; // Получатель, который был указан при передаче письма
$headers = [];
$mailBody = null;
$prevLine = null;
// Собираем заголовки
while (($line = fgets(STDIN)) !== false) {
if (trim($line) === '') {
$mailBody = ltrim(stream_get_contents(STDIN));
break;
}
if (empty($prevLine)) {
$prevLine = $line;
continue;
}
if (preg_match('/^\s/', $line)) {
$prevLine .= $line;
continue;
}
$headers[] = $prevLine;
$prevLine = $line;
}
if ($prevLine) {
$headers[] = $prevLine;
}
if (empty($headers)) {
return;
}
// Целое письмо
$data = join('', $headers) . "\r\n" . $mailBody;
// Парсим заголовки
$rfc822Headers = imap_rfc822_parse_headers(join('', $headers));
// Состсавляем метаданные
$meta = [
'smtpSender' => $smtpSender,
'smtpRecipient' => $smtpRecipient,
'from' => mb_decode_mimeheader($rfc822Headers->fromaddress ?? ''),
'replyto' => mb_decode_mimeheader($rfc822Headers->reply_toaddress ?? ''),
'recipient' => mb_decode_mimeheader($rfc822Headers->toaddress ?? ''),
'subject' => mb_decode_mimeheader($rfc822Headers->subject ?? ''),
'size' => strlen($data),
];
// Сохраняем метаданные
$id = Db_save('mail_inbox', [
'meta' => json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
// Сохраняем письмо
Db_save('mail_inbox_data', [
'id' => $id,
'data' => $data,
]);
ob_end_clean();
// Передаём информацию в лог
fputs(STDERR, 'Saved to db with id #' . $id . PHP_EOL);
Файл view.php
#!/usr/bin/env php
<?php
include '.init.php';
$mailId = $argv[1] ?? null; // Идентификатор письма
$meta = Db_select_value('mail_inbox', 'meta', 'id = ' . intval($mailId));
if (!$meta) {
die("Mail #$mailId not found\n");
}
// Данные письма
$meta = json_decode($meta, true);
$itemData = Db_select_value('mail_inbox_data', 'data', 'id = ' . intval($mailId));
// Парсим письмо
$msg = mailparse_msg_create();
mailparse_msg_parse($msg, $itemData);
$text = null;
$html = null;
$images = [];
$headers = mailparse_msg_get_part_data($msg)['headers'];
$ids = [];
foreach (mailparse_msg_get_structure($msg) as $section) {
$part = mailparse_msg_get_part($msg, $section);
$data = mailparse_msg_get_part_data($part);
$contentType = $data['content-type'] ?? '';
$contentId = $data['content-id'] ?? null;
if ($contentType == 'text/html') {
$html = mailparse_msg_extract_part($part, $itemData, null);
} elseif ($contentType == 'text/plain') {
$text = mailparse_msg_extract_part($part, $itemData, null);
} elseif (preg_match('~image~', $contentType)) {
if ($contentId) {
$images[$contentId] = [$section, $contentType];
$ids[] = preg_quote($contentId);
}
}
}
// Выводим метаданные
print_r($meta);
// Тело письма
if ($html) {
// Вставляем картинки
$html = preg_replace_callback('~cid:(' . join('|', $ids) . ')~ui', function ($matches) use ($itemData, $images, $msg) {
$id = $matches[1];
$part = mailparse_msg_get_part($msg, $images[$id][0]);
$data = mailparse_msg_extract_part($part, $itemData, null);
return 'data:' . $images[$id][1] . ';base64,' . base64_encode($data);
}, $html);
echo $html;
} elseif ($text) {
echo $text;
} else {
echo 'No data to view';
}
echo PHP_EOL;
mailparse_msg_free($msg);
Устанавливаем правильные права на файлы:
chmod -R a+r /var/www/mail
chmod a+x /var/www/mail/*
Проверяем работу:
$ /var/www/mail/save.php sender@test recipient@test <<EML
From: "test" <test@example.org>
To: "test" <test@example.com>
Subject: Test SMTPS
This is just a test mail
EML
Saved to db with id #1
$ /var/www/mail/view.php 1
Array
(
[smtpSender] => sender@test
[smtpRecipient] => recipient@test
[from] => test <test@example.org>
[replyto] => test <test@example.org>
[recipient] => test <test@example.com>
[subject] => Test SMTPS
[size] => 109
)
This is just a test mail
Теперь можно отправлять письма через обычную почту (если настроена MX-запись) и смотреть логи:
$ tail /var/log/mail.log -f | grep myhook
Aug 7 18:39:12 test postfix/pipe[15894]: 63C373FECF: to=<test@test.anton-pribora.ru>, relay=myhook, delay=0.78, delays=0.65/0.02/0/0.1, dsn=2.0.0, status=sent (delivered via myhook service (Saved to db with id #6))
Aug 7 18:40:12 test postfix/pipe[15894]: B30663FE5A: to=<test@test.anton-pribora.ru>, relay=myhook, delay=438, delays=438/0.01/0/0.06, dsn=2.0.0, status=sent (delivered via myhook service (Saved to db with id #7))