Обработка входящей почты через PHP (debian, postfix, php)

Предполагается, что был установлен POSTFIX и включен SMTP (для входящей почты нужны только MX-запись, SMTP и TLS, остальное настраивать необязательно).

  1. MX-запись
  2. Настройки POSTFIX
  3. Скрипт для сохранения почты

MX-запись

Чтобы письма приходили на сервер, нужно чтобы на него ссылалась MX-запись. Проверяем, настроена ли MX-запись:

host -t mx $(hostname --fqdn)

Если записи нет, нужно зайти в панель управления доменом и добавить её.

Настройки POSTFIX

Все команды необходимо выполнять в консоли от суперпользователя.

Редактируем конфиги 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.

[Наверх]

Скрипты PHP

Создайте папку для хранения файлов-обработчиков:

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))

[Наверх]

Хорошая статья, мне понравилась. Оставлю отзыв!