Статьи

Борьба со спамом рекрутера с помощью PHP — подтверждение концепции

С тех пор, как я перестал пользоваться сервисами Google (из-за качества , а не из-за проблем с конфиденциальностью), я искал идеальный почтовый сервис. Перепробовав несколько и побывав некоторое время в FastMail , я понял, что такого нет . Самая большая проблема, с которой я сталкиваюсь с современными почтовыми провайдерами, заключается в том, что все они довольно плохо контролируют спам.

Я не имею в виду спам типа «нигерийский принц», который в большинстве случаев успешно блокируется (если вы не пользуетесь FastMail — они даже не могут его распознать), но вещи, которые я действительно, действительно не заинтересован в получении. Показательный пример, вербовщик спам .

Иллюстрация заблокированной электронной почты

В этом уроке мы начнем с создания собственного обработчика электронной почты, который может читать отдельные электронные письма, выполнять их по некоторым заранее определенным правилам и действовать в соответствии с ними. Конечный результат будет очень похож на то, что многие провайдеры предлагают из коробки, но он заложит основу для более сложных аспектов в будущих публикациях. Пример использования нашего приложения:

  • когда ключевые слова типа рекрутера обнаружены, ответьте на электронное письмо с ответом шаблона и удалите его. В некоторой степени это возможно с правилами, которые предлагает большинство поставщиков электронной почты, но они не очень подробны и обычно не поддерживают переменные.
  • когда компании продолжают отправлять вам электронные письма даже после того, как вы отменили подписку или сообщили о них как о спаме (например, Ello), движок должен помнить об этом и в будущем автоматически удалять их. Некоторые провайдеры (например, FastMail) не будут мешать отправителю войти в ваш почтовый ящик даже после сотен сообщений о спаме .

Таким образом, мы можем сохранить провайдера, к которому мы привыкли, но также сделать некоторые ручные улучшения, которые их команда просто не знала, как это сделать.

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

Бутстрапирование

Не стесняйтесь использовать свою собственную среду, если у вас есть одна настройка — мы будем использовать нашу коробку Homestead Improved, как обычно, чтобы создать готовую среду в считанные минуты. Чтобы следовать, выполните следующее:

git clone https://github.com/swader/homestead_improved hi_mail cd hi_mail ./bin/folderfix.sh vagrant up; vagrant ssh mkdir -p Project/public 

У вас уже должен быть файл homestead.app в вашем etc/hosts если вы ранее использовали Homestead Improved . Если нет, добавьте его в соответствии с инструкциями. Сайт по умолчанию, включенный в коробку, указывает на ~/Code/Project , что достаточно для нас.

Оказавшись внутри коробки, мы создадим файл index.php в ~/Code/Project/public с некоторым демонстрационным кодом:

 <?php phpinfo(); 

Этот экран сразу говорит нам, что нам нужно знать: установлен php-imap или нет?

снимок экрана php-imap, отображаемый на экране phpinfo

Конечно же, он поставляется с коробкой Homestead Improved . Если вам не хватает php-imap, следуйте инструкциям, чтобы установить его — он нам понадобится, прежде чем двигаться дальше (в Ubuntu sudo apt-get install php7.0-imap ).

В качестве последнего шага начальной загрузки давайте установим пакет, который мы будем использовать для взаимодействия с нашей папкой входящих сообщений IMAP: tedivm / fetch .

 composer require tedivm/fetch 

Конечно, нам нужно изменить наш файл index.php включив в него также автозагрузчик Composer:

 <?php require_once `../vendor/autoload.php`; 

Чтение почтовых ящиков IMAP

У меня есть и учетная запись Gmail, и учетная запись Fastmail. В приведенных ниже примерах будут использоваться оба варианта, чтобы мы могли показать различия между двумя входящими почтовыми ящиками и применить твики по мере необходимости, чтобы сделать наш проект независимым от поставщика.

В PHP встроенные функции imap работают как собственные file функции — вы создаете дескриптор, а затем передаете его другим функциям. API старый (действительно, очень старый!), Поэтому он существует только в этой процедурной форме. Вот почему, когда мы можем, мы будем использовать библиотеку Fetch, которую мы установили ранее.

Gmail — базовая загрузка

Давайте начнем с шагов ребенка и войдите в нашу учетную запись Gmail. Во-первых, в настройках учетной записи и на вкладке « Пересылка» и «POP / IMAP » убедитесь, что параметр «Включить IMAP» активирован.

Включен IMAP в Gmail

 <?php require_once '../vendor/autoload.php'; use Fetch\Server; $server = new Server('imap.googlemail.com', 993); $server->setAuthentication('[email protected]', 'password'); $messages = $server->getMessages(); /** @var $message \Fetch\Message */ foreach ($messages as $i => $message) { echo "Subject for {$i}: {$message->getSubject()}\n<br>"; } 

Следуя документам Fetch, мы пытаемся установить соединение с нашей учетной записью Gmail. К сожалению, если у вас активирована 2FA ( двухфакторная аутентификация ), вы увидите следующую ошибку:

Исключение, запрашивающее специальный пароль приложения для Gmail

Это легко исправить. Мы можем перейти на страницу паролей приложения в нашей учетной записи Google и сгенерировать ее (выберите «Другое» в меню, введите произвольное имя и скопируйте пароль в код). Теперь, если мы проверим вещи …

Gmail темы электронной почты отображаются на экране

Отлично — мы получили наши письма Gmail. Теперь давайте подключимся к Fastmail.

FastMail — базовая выборка

Как и Gmail, Fastmail также поддерживает пароли приложений, но они требуются независимо от того, используете вы 2FA или нет. Создайте один здесь .

Генерация пароля приложения для Fastmail

Значения для FastMail следующие:

 $server = new Server('imap.fastmail.com', 993); $server->setAuthentication('[email protected]', 'password'); $messages = $server->getMessages(); /** @var $message \Fetch\Message */ foreach ($messages as $i => $message) { echo "Subject for {$i}: {$message->getSubject()}\n<br>"; } 

Обратите внимание, что сообщения извлекаются не вложенным способом, поэтому даже если в вашем почтовом ящике может отображаться число, подобное 100, реальное количество найденных электронных писем может быть намного больше, в случае, если некоторые электронные письма являются ответами, сгруппированы для контекста и Больше.

И в Gmail, и в Fastmail по умолчанию используется папка «Входящие», а это именно то, что нам нужно.

Целевые электронные письма

В зависимости от количества писем в ваших почтовых ящиках, вы могли заметить огромную проблему с производительностью — их извлечение заняло целую вечность! Очевидно, что это не может работать, если мы хотим своевременно обрабатывать входящие электронные письма. В конце концов, наша цель состоит в том, чтобы обрабатывать все входящие электронные письма и обрабатывать их, если это возможно.

К сожалению, поскольку спецификация электронной почты была разработана еще в каменный век Интернета, нет никакого собственного способа получать push-уведомления при получении нового электронного письма. Есть и другой способ.

IMAP поддерживает поиск, и это может включать статусы флагов. Согласно документам , передача «UNSEEN» должна возвращать все непрочитанные сообщения:

 $messages = $server->search('UNSEEN'); 

Одно невидимое сообщение из почтового ящика Gmail

Конечно же, наше электронное письмо с сообщением о том, что мы успешно создали пароль приложения для этого самого приложения, которое мы создаем, все еще не прочитано и находится в папке «Входящие». Успех, и звонок был достаточно быстрым!

Сканирование писем

Теперь, когда мы знаем, как извлекать непрочитанные сообщения, пришло время проанализировать их и выполнить некоторые действия с ними, если они вызовут наши проверки правил.

Давайте рассмотрим первый пример — избавление от спам-рекрутера. Письма рекрутера бывают разных форм и размеров, поэтому не существует абсолютного способа идентифицировать их все. Вместо этого мы должны полагаться на несколько указателей, сумма которых, если задано числовое значение, может превысить заданный порог. Например, если 100 — это пороговое значение, необходимое для пометки электронного письма как спам-рекрутера, мы можем создать следующую таблицу:

правило Значение оч
содержит найти возможности для ИТ 100
содержит Специалисты по PHP? 80
содержит стартапы? 10
содержит видел ваш профиль на GitHub 50
содержит explore-group.com 100
из @ explorerec.com 100
содержит Новая позиция 20
содержит срочно)? необходимость 30
содержит огромный плюс 15
содержит полный стек разработчика 30
содержит интервью? 20
содержит резюме 60
содержит навыки и умения 10
содержит кандидаты? 20

Я хотел бы поблагодарить всех моих подписчиков в Твиттере, которые прислали несколько примеров спама по электронной почте и помогли составить приведенную выше таблицу.

Группа explore-group относится к группе жестоко стойких спамеров , поэтому их электронные письма автоматически вызывают оповещение о спаме от вербовщика.

Значения являются регулярными выражениями — это позволяет нам выполнять частичные совпадения, что особенно полезно при распознавании доменов отправителей, или строк, которые могут незначительно отличаться, но по сути одинаковы, как «специалист по PHP» и «специалисты по PHP».

Ради производительности имеет смысл проверять правила в порядке убывания их значения баллов. Если только один из них запускает 100, тогда нет необходимости проверять остальные.

Давайте посмотрим код для этого. Пожалуйста, простите за спагетти- природу кода — так как это всего лишь доказательство концепции, оно будет ООП-редактировано и упаковано в следующей статье.

 <?php require_once '../vendor/autoload.php'; use Fetch\Message; use Fetch\Server; $inboxes = [ '[email protected]' => [ 'username' => '[email protected]', 'password' => 'password', 'aliases' => ['[email protected]', '[email protected]'], 'smtp' => 'smtp.googlemail.com', 'imap' => 'imap.googlemail.com' ], '[email protected]' => [ 'username' => 'someusername', 'password' => 'password', 'aliases' => ['[email protected]'], 'smtp' => 'smtp.fastmail.com', 'smtp_port' => '587', 'imap' => 'imap.fastmail.com', 'starttls' => true ] ]; $rules = [ ['contains' => 'finding IT opportunities', 'points' => 100], ['contains' => 'PHP specialists?', 'points' => 80], ['contains' => 'startups?', 'points' => 10], ['contains' => 'saw your profile on GitHub', 'points' => 50], ['contains' => 'explore-group\.com', 'points' => 100], ['from' => '@explorerec\.com', 'points' => 100], ['contains' => 'new position', 'points' => 20], ['contains' => 'urgent(ly)? need', 'points' => 30], ['contains' => 'huge plus', 'points' => 15], ['contains' => 'full-stack developer', 'points' => 30], ['contains' => 'interviews?', 'points' => 20], ['contains' => 'CV', 'points' => 60], ['contains' => 'skills', 'points' => 10], ['contains' => 'candidates?', 'points' => 20], ]; $points = []; foreach ($rules as $key => &$rule) { $points[$key] = $rule['points']; if (isset($rule['contains'])) { $rule['contains'] = '/' . $rule['contains'] . '/i'; } if (isset($rule['from'])) { $rule['from'] = '/' . $rule['from'] . '/i'; } } array_multisort($points, SORT_DESC, $rules); $unreadMessages = []; foreach ($inboxes as $id => $inbox) { $server = new Server($inbox['imap'], 993); $server->setAuthentication($inbox['username'], $inbox['password']); $unreadMessages[$id] = $server->search('UNSEEN'); } foreach ($unreadMessages as $id => $messages) { echo "Now processing: ".$id. "<br>"; /** * @var Message $message */ foreach ($messages as $i => $message) { $spam = isRecruiterSpam($rules, $message) ? '' : 'not'; echo "Subject for {$i}: {$message->getSubject()} is probably {$spam} recruiter spam.\n<br>"; } } function isRecruiterSpam($rules, Message $message) { $sum = 0; foreach ($rules as $rule) { if (isset($rule['contains'])) { if (preg_match($rule['contains'], $message->getSubject()) || preg_match($rule['contains'], $message->getHtmlBody()) ) { $sum += $rule['points']; } } else { if (isset($rule['from'])) { if (preg_match($rule['from'], $message->getOverview()->from) ) { $sum += $rule['points']; } } } if ($sum > 99) { return true; } } return false; } 

Сначала мы определяем наши почтовые ящики и все необходимые значения конфигурации. Затем мы сортируем массив правил по значению ключа points и превращаем строки в регулярные выражения, добавляя разделители. Затем мы извлекаем все невидимые сообщения из всех наших учетных записей, а затем перебираем их.

В этот момент мы вызываем функцию isRecruiterSpam для каждого, которая, в свою очередь, захватывает поле from , а также тему и тело HTML и запускает проверки для них. После каждого правила мы проверяем, превысила ли $sum 100 баллов, и если да, то возвращаем true — мы совершенно уверены, что сообщение является спам-вербовщиком в этот момент. В противном случае мы продолжаем суммировать и, наконец, возвращаем false, если все правила проверены и результат все еще меньше 100.

Сообщения не были обнаружены как спам-рекрутер

В моем первоначальном тесте ни одно сообщение не было помечено как спам-рекрутер. Давайте попробуем переслать предыдущую с другой учетной записи электронной почты и посмотрим, что произойдет.

Два сообщения были помечены как спам-рекрутер!

Успех! Наш двигатель успешно распознал вербовщика спама! Теперь давайте посмотрим на ответ.

Отправка ответов

Чтобы ответить на сообщение, нам нужно вытащить другой пакет. Давайте сделаем это SwiftMailer, так как это де-факто проверенный в бою стандарт отправки электронных писем из PHP.

 composer require swiftmailer/swiftmailer 

Мы не будем разбираться здесь с основами SwiftMailer, это задокументировано в другом месте .

Давайте подумаем о том, что нужно сделать сейчас:

  1. Сообщение, прочитанное и идентифицированное как спам-рекрутер, должно быть помечено как прочитанное, в противном случае оно будет продолжаться при последующих поисках.
  2. При ответе ответ должен поступать с адреса электронной почты, на который он был отправлен.
  3. В идеале автоответчик должен быть помещен в другую папку на сервере. Это полезно для периодической проверки и выявления ложных срабатываний.

Определив требования, давайте посмотрим, как может выглядеть код.

 foreach ($unreadMessages as $id => $messages) { echo "Now processing: " . $id . "<br>"; if (!empty($messages)) { $mailer = Swift_Mailer::newInstance( Swift_SmtpTransport::newInstance( $inboxes[$id]['smtp'], $inboxes[$id]['smtp_port'], (isset($inboxes[$id]['starttls'])) ? 'tls' : null ) ->setUsername($inboxes[$id]['username']) ->setPassword($inboxes[$id]['password']) ->setStreamOptions( [ 'ssl' => [ 'allow_self_signed' => true, 'verify_peer' => false, ], ] ) ); } else { continue; } /** * @var Message $message */ foreach ($messages as $i => $message) { if (isRecruiterSpam($rules, $message)) { $message->setFlag(Message::FLAG_SEEN); $potentialSender = $message->getAddresses('to')[0]['address']; $sender = (in_array($potentialSender, $inboxes[$id]['aliases'])) ? $potentialSender : $inboxes[$id]['aliases'][0]; $reply = Swift_Message::newInstance('Re: ' . $message->getSubject()) ->setFrom($message->getAddresses('to')[0]['address']) ->setTo($message->getAddresses('from')['address']) ->setBody( file_get_contents('../templates/recruiter.html'), 'text/html' ); $result = $mailer->send($reply); } } } 

В двух словах:

  • если какой-либо из входящих ключей в $unreadMessages имеет непустой массив (что означает, что он имел некоторый спам-рекрутер), мы инициируем почтовую программу — это по соображениям производительности. Если у нас много почтовых ящиков, мы не хотим создавать экземпляр Mailer даже для чистых почтовых ящиков.
  • мы перебираем эти обнаруженные спам-сообщения с подготовленной почтой, а затем строим ответ по электронной почте. Для получателя мы выбираем отправителя, а для отправителя выбираем либо первый адрес в списке получателей исходного письма, если он входит в число псевдонимов, определенных для этой папки входящих сообщений, либо, если нет, первый псевдоним из списка. Это связано с тем, что некоторые рекрутеры настолько ленивы, что рассылают тысячи писем по почте, выставляют себя в качестве получателя и помещают «жертв» в BCC.
  • наконец, мы берем содержимое готового шаблона письма, чтобы вставить его в текст письма и отправляем.

На данный момент нам нужно написать шаблон электронной почты:

 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Recruiter reply</title> </head> <body> <p>Hello!</p> <p>Please do not be alarmed by the quick reply - it's automated.</p> <p>Based on your email's contents, you sound like a recruiter. As such, before getting back in touch with me, please read <a href="https://www.linkedin.com/pulse/20140516082146-67624539-dear-recruiters?trk=prof-post">this</a>.</p> <p>In case we misidentified your intentions and your email, we apologize - our bot is still young and going through some growing pains. If that's the case and you have a genuine non-recruiter concern you'd like to discuss, please feel free to reply directly to this email.</p> <p>Kind regards,<br>Bruno</p> </body> </html> 

Конечно же, наш ответ возвращается, как и планировалось.

Автоматический ответ на спам вербовщика

Whitelisting

Но что произойдет, если рекрутер ответит на наш ответ? Вы можете подумать, что он снова получит автоответ, поскольку цепочки писем содержат цитированные письма ранее. Не так! Именно провайдеры электронной почты связывают электронные письма в цепочки — фактический ответ не содержит ничего, кроме нашего собственного контента, поэтому нет причин беспокоиться о том, что кто-то застрянет в цикле ответа с нашим автоматическим механизмом.

Тем не менее, мы можем позаботиться о том, чтобы этот крайний случай был в безопасности, поместив некоторый дополнительный контент внизу нашего шаблона, например:

 ... <p>Kind regards,<br>Bruno</p> <p style="text-align: right"><em>sent via our-little-app</em></p> </body> </html> 

Мы можем нацелить этот текст в наших правилах следующим образом:

правило Значение оч
содержит отправлено через наше маленькое приложение -1000

… но тогда нам пришлось бы удалить механизм раннего обнаружения, который запускает положительную идентификацию, как только будет достигнуто 100 баллов, и было бы неуклюже держать эти правила белого списка в том же наборе, что и правила черного списка.

Будет лучше, если мы создадим совершенно новый механизм обнаружения для полного пропуска сканирования в черный список. Это будет отдельный цикл, который также проверяет тело, тему и заголовки, но влияние на производительность будет незначительным, потому что Fetch кэширует свойства сообщения для повторных вызовов (содержимое извлеченного сообщения не может измениться — если оно изменяется, это новое сообщение — меняются только флаги).

Мы isWhitelisted новую функцию isWhitelisted и вызовем ее перед проверкой других правил.

  if (!isWhitelisted($whitelistRules, $message) && isRecruiterSpam($rules, $message) ) { // ... function isWhitelisted($rules, Message $message) { foreach ($rules as $rule) { if (isset($rule['contains'])) { if (preg_match($rule['contains'], $message->getSubject()) || preg_match($rule['contains'], $message->getHtmlBody()) ) { return true; } } else { if (isset($rule['from'])) { if (preg_match($rule['from'], $message->getOverview()->from) ) { return true; } } } } return false; } 

Вы заметите, что он почти идентичен isRecruiterSpam , только нет никаких очков.

Естественно, нам также нужен массив $whitelistRules , который на данный момент довольно мал:

 $whitelistRules = [ ['contains' => '/sent via our-little-app/i'], ]; 

Благодаря этому мы не только позаботились о том, чтобы электронные письма, содержащие наш автоответчик, игнорировались, но мы также можем легко пропустить электронные письма от людей / доменов, которых мы знаем и которым доверяем. В сочетании с API контактов многие провайдеры предоставляют нам огромные возможности по работе с почтовыми ящиками.

Папки

Последним требованием для проверки концепции было перемещение сообщений, на которые мы автоматически отвечали, в отдельную папку. API IMAP имеет поддержку папок, и Fetch реализовал это довольно плавно, так что это всего лишь пара строк кода.

Во-первых, мы создадим папку с именем «autoreplied» в каждой папке входящих, если она не существует.

  $server = new Server($inbox['imap'], 993); $server->setAuthentication($inbox['username'], $inbox['password']); if (!$server->hasMailBox('autoreplied')) { $server->createMailBox('autoreplied'); } $unreadMessages[$id] = $server->search('UNSEEN'); 

Затем, после отправки ответа, мы переместим (скопируем и удалим) сообщение, на которое мы отвечаем, в эту папку.

 $result = $mailer->send($reply); if ($result) { $message->moveToMailBox('autoreplied'); } 

Конечно же, он перемещает сообщение во вновь созданную папку:

Сообщение, на которое мы автоматически отвечали, было помечено как прочитанное и перемещено в отдельную папку

Вывод

В этом уроке мы коснулись чтения наших входящих сообщений с последними сообщениями, прохождения их по некоторым правилам и выполнения некоторых действий с ними в зависимости от того, что сказано в правилах. Теперь у нас есть способ фильтровать наши почтовые ящики программно!

К настоящему времени вы, вероятно, заметили несколько проблем с этой реализацией, некоторые из которых могут быть:

  • нет способа динамически определять правила или белые списки. Вы должны изменить код. Может пригодиться база данных и система входа в систему с своего рода интерфейсом CRUD.
  • нет никакого кэширования, поэтому каждый вызов выполняется довольно медленно.
  • По мере isRecruiterSpam новых правил и условий функция isRecruiterSpam будет становиться все более и более сложной, в конечном итоге достигая уровня полного хаоса. Это то, что нам нужно исправить, если мы хотим гибкую, масштабируемую, динамичную систему — особенно если мы хотим идентифицировать больше типов электронных писем, чем просто спам-рекрутер!
  • добавление большей функциональности в это приложение в лучшем случае утомительно — мы нарушаем каждый принцип SOLID с помощью этого кода и нуждаемся в рефакторинге. В идеале мы хотим, чтобы приложение могло использоваться несколькими пользователями одновременно. Не только это, но мы также хотим поделиться некоторыми данными обучения между пользователями, для лучшей защиты от спама.

Мы рассмотрим все эти проблемы в последующих статьях — теперь, когда мы знаем все, что нам нужно, и наши доказательства концепции работают, мы можем очистить код и превратить его во что-то стоящее усилий.

В дальнейшем мы превратим наш эксперимент со спагетти-скриптом в многопользовательское приложение, должным образом спроектировав и структурировав его. Мы также приведем его в действие cronjob и начнем создавать правильный механизм правил. Будьте на связи!