15 07.2015

Analiza zwrotek w marketingu email, część pierwsza

Miesiąc temu rozpoczęliśmy cykl porad dla email marketerów, pokazując sposób na poprawienie jakości tanich baz adresów email i dając Wam za darmo gotowy, działający skrypt.

W dzisiejszym odcinku skupimy się na odbieraniu i przetwarzaniu tzw. zwrotek, czyli odpowiedzi od serwerów poczty na maile wysłane adresy nieistniejące, z przepełnioną skrzynką, bądź maile wykryte jako spam.

Wynikiem dzisiejszego odcinka będą:

  • ogarnięta skrzynka pocztowa, której używasz do odbierania zwrotek - zwrotki zostaną przeniesione do wybranego podkatalogu, dzięki czemu w głównym katalogu skrzynki zostaną Ci tylko maile do ręcznej analizy
  • plik CSV zawierający listę zwrotek - w drugiej części zajmiemy się analizą tego pliku i klasyfikacją poszczególnych zwrotek na przyczyny (np. konto usunięte, domena wygasła, skrzynka zapełniona, wykryto spam i inne)


Opisane poniżej techniki sprawdziliśmy w praktyce, prowadząc przez prawie 2 lata serwis LegalnyMailing.pl, wysyłając w tym czasie kilkaset milionów maili i odbierając miliony zwrotek.

Geneza podziału na dwie części

Obsługa zwrotek celowo została podzielona na dwie osobne części ze względów bezpieczeństwa. Część pierwsza, opisywana poniżej, posiada bowiem dostęp do produkcyjnego serwera poczty, który jest u nas szczególnie chroniony. Z drugiej strony jest to serwer typu low-power, którego celem jest przetrzymanie możliwie jak najdłuższej awarii zasilania na stosunkowo niewielkim zasilaczu UPS.

Druga część jest zaś silnie zintegrowana z całą resztą skryptów do obsługi marketingu email i jest uruchamiana na serwerze o dużej mocy obliczeniowej. Pliki CSV pomiędzy pierwszą a drugą częścią są u nas manualnie kopiowane przed administratora.

Skąd taki podział? Chodzi o maksymalne ograniczenie wpływu tzw. opóźnień sieciowych (network latency) na działanie skryptu z pierwszej części. Oczywiście można go uruchamiać z osobnego serwera (a nawet z innej serwerowni), jednak należy się wówczas liczyć z o wiele wolniejszym działaniem, szczególnie jeśli przetwarzamy jednorazowo więcej niż kilka tysięcy zwrotek.

Instalacja pakietu Klim Framework

Opisywany niżej skrypt jest napisany w języku PHP, w oparciu o darmowy pakiet Klim Framework. Klim Framework jest pakietem open source, którego możecie bez ograniczeń używać do własnych zastosowań.

Celem stworzenia tego pakietu było ujednolicenie sposobu korzystania w PHP z różnych baz danych, mechanizmów cache i transferu plików, tak aby możliwe było napisanie kodu aplikacji tylko raz, a następnie migracja tej aplikacji na coraz większe silniki baz danych bez zmian w kodzie.

Aby móc używać opisanego niżej skryptu, musisz najpierw zainstalować Klim Framework. Wykonaj w tym celu poniższe polecenia jako root:


mkdir -p /app/mailfilter/b-fajneit/background
mkdir -p /app/cache
git clone https://github.com/tomaszklim/klim-framework /app/libs

echo prod >/etc/environment-type

mkdir /var/log/php
chown www-data:www-data /var/log/php
chmod g+w /var/log/php

Docelowy skrypt zapisz w katalogu /app/mailfilter/b-fajneit/background - nazwa skryptu jest dowolna, natomiast ten konkretny katalog jest istotny, ponieważ Klim Framework automatycznie wykrywa katalog i ustawia na jego podstawie tzw. include_path. Pierwotnym celem takiego zachowania było umożliwienie uruchamiania na jednym serwerze deweloperskim wielu kopii (tzw. branchy) tego samego kodu, tak aby każdy każdy programista mógł niezależnie pracować na swojej kopii.

Właściwy skrypt

Zacznijmy konstruować właściwy skrypt do odbierania zwrotek. Na początek inicjalizacja:


require_once "/app/libs/bootstrap.php";
Bootstrap::run();
Bootstrap::addLibrary("klim-framework-1.0");
Bootstrap::addLibrary("lemos-mimeparser-2011-05-05");
require_once "klim/loader.php";
require_once "rfc822_addresses.php";
require_once "mime_parser.php";

Teraz podstawowe funkcje, których będziemy używać w skrypcie:


function filter_address_lemos( $struct )
{
	if ( empty($struct) ) {
		return false;
	} else if ( empty($struct["name"]) ) {
		$struct["name"] = false;
	} else if ( !empty($struct["encoding"]) ) {
		$struct["name"] = iconv(
			$struct["encoding"], "utf-8//TRANSLIT", $struct["name"]
		);
		unset( $struct["encoding"] );
	}
	return $struct;
}

function mime_decode_lemos( $raw )
{
	$engine = new mime_parser_class();

	$parameters = array( "Data" => $raw );
	$decoded = array();

	if ( !$engine->Decode($parameters, $decoded) ) {
		echo "cannot decode message: $engine->error\n";
		return false;
	}

	$attachments = 0;
	$headers = array();
	$bodies = array();
	$to = array();

	foreach ( $decoded[0]["Headers"] as $hk => $hv )
		$headers[rtrim($hk, ":")] = $hv;

	$parts = $decoded[0]["Parts"];
	$addresses = $decoded[0]["ExtractedAddresses"];

	if ( !empty($decoded[0]["Body"]) )
		$bodies[] = KlimMime::decodeContent(
			$decoded[0]["Body"],
			@$headers["content-type"],
			@$headers["content-transfer-encoding"],
			"utf-8"
		);

	foreach ( $parts as $part ) {
		$part_headers = $part["Headers"];
		$content_type = @$part_headers["content-type:"];
		$content_disposition = @$part_headers["content-disposition:"];

		if (strpos($content_type, "name=") !== false
		 || strpos($content_disposition, "attachment") !== false)
			$attachments++;
		else if ( isset($part["Body"]) )
			$bodies[] = KlimMime::decodeContent(
				$part["Body"],
				$content_type,
				@$part_headers["content-transfer-encoding:"],
				"utf-8"
			);
	}

	if ( !empty($addresses["to:"]) )
		foreach ( $addresses["to:"] as $addr )
			$to[] = filter_address_lemos( $addr );

	return array (
		"attachments" => $attachments,
		"headers" => $headers,
		"subject" => KlimMime::decodeHeader($headers["subject"], "utf-8"),
		"bodies" => $bodies,
		"to" => $to,
		"from" => filter_address_lemos(@$addresses["from:"][0]),
		"reply_to" => filter_address_lemos(@$addresses["reply-to:"][0]),
		"return_path" => filter_address_lemos(@$addresses["return-path:"][0]),
	);
}

Teraz najważniejsza część skryptu, czyli tabele z regułami detekcji zwrotek:


$email = "([a-zA-Z0-9_\.-]+\@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*\.[a-zA-Z]{2,5})";
$patterns = array (
	array( "/from: <$email>\)/", "(expanded from" ),
	array( "/<$email>:/", "Sorry, I couldn't find a mail exchanger" ),
	array( "/<$email>:/", "Sorry, I couldn't find any host" ),
	array( "/<$email>:/", "Sorry, I wasn't able to establish an SMTP connection." ),
	array( "/<$email>:/", "Sorry, I've been told to reject any post." ),
	array( "/<$email>:/", "Sorry, no mailbox here by that name." ),
	array( "/<$email>:/", " but greeting failed." ),
	array( "/<$email>:/", " but connection died." ),
	array( "/<$email>:/", "unknown user:" ),
	array( "/<$email>:/", "qmail-group: fatal: no recipients found in this group." ),
	array( "/<$email>:/", "User unknown in virtual" ),
	array( "/<$email>:/", "Podane konto" ),
	array( "/<$email>:/", "loops back to myself" ),
	array( "/\($email\)/", "not listed in Domino Directory" ),
	array( "/$email/",    "Over quota" ),
	array( "/$email/",    "Mailbox quota exceeded" ),
	array( "/$email/",    "Action: failed" ),
);

$google = array (
	"Message rejected by Google Groups.",
);

$ignore = array (
	"YOU DO NOT NEED TO RESEND YOUR MESSAGE",
);


W tym momencie przerwijmy na chwilę i wyjaśnijmy jedną rzecz: powyższy fragment skryptu zawiera tabelę z 17 regułami. Nasz kompletny skrypt ma 126 takich reguł, jak również po 3 wpisy w kolejnych tabelach, dzięki czemu jest w stanie znaleźć i odfiltrować wszystkie zwrotki, a nie tylko niektóre.

Skrypt ten możesz kupić w naszym sklepie już za 50 zł (zobacz nasz pełny cennik lub nie trać czasu i od razu kup go, płacąc kartą lub przelewem z dowolnego banku). Możesz również kupić go w komplecie ze skryptem do naprawiania baz adresów email za jedyne 80 zł, oszczędzając w ten sposób aż 40% jego ceny.

Poza mniejszą ilością reguł, opisywany tutaj skrypt nie posiada żadnych innych ograniczeń. Nie musisz kupować naszej wersji, możesz złożyć skrypt z fragmentów w tym artykule, a brakujące reguły dopisywać eksperymentalnie, samodzielnie analizując zwrotki, które nie zostały odfiltrowane po jego kolejnych uruchomieniach.


Przejdźmy teraz do konfiguracji konta pocztowego. Ta część jest zdecydowanie najprostsza. String "/novalidate-cert" jest potrzebny tylko w sytuacji, gdy na Twoim serwerze IMAP jest certyfikat SSL self-signed zamiast komercyjnego. Oczywiście skrypt może również działać z użyciem połączenia nieszyfrowanego na porcie 143.


$server = array (
	"server" => "myimapserver.internal:993/imap/ssl/novalidate-cert",
	"username" => "myuser",
	"password" => "mypassword",
);

Na koniec zobaczmy jeszcze pętlę główną skryptu, czyli część, w której zaszyta jest jego właściwa logika biznesowa:


if ( empty($argv[1]) || empty($argv[2]) ) {
	$script = $argv[0];
	die("usage: $script  \n");
}

if ( !empty($server["password"]) )
	$password = $server["password"];
else
	$password = KlimPassword::getFromFile( "imap", $server["passfile"] );

$dbname = "failure_parser";
KlimDatabasePool::addDatabase(
	$dbname,
	"imap",
	$server["server"],
	$server["username"],
	$password,
	$argv[1],  // nazwa katalogu źródłowego, np. INBOX
	"utf-8"
);

$db = new KlimDatabase();
$connection = $db->getConnection( $dbname, DB_DIE );
$folders = $connection->getFolders();
sort( $folders );

$xfr = "x-failed-recipients";

// nazwa katalogu docelowego, do którego mają trafić
// znalezione i przetworzone zwrotki, np. INBOX.przetworzone
$folder = $argv[2];

// tą datę możemy ustawić wg własnych preferencji, będzie
// ona przypisywana do tych zwrotek, w których nie da się
// jednoznacznie wyciągnąć z nagłówka daty wysłania zwrotki
$base = "2014-11-22 00:00:00";

$db->begin( $dbname );
$response = $db->rawQuery( $dbname, "ALL" );

foreach ( $response as $row ) {
	$mime = mime_decode_lemos( $row["message"] );
	$found = false;

	if ( empty($mime["headers"]["date"]) )
		$date = $base;
	else
		$date = KlimTime::getTimestamp( GMT_DB, $mime["headers"]["date"] );

	foreach ( $mime["bodies"] as $body ) {
		$groups = explode( "\r\n\r\n", $body );

		foreach ( $groups as $group ) {
			foreach ( $ignore as $pattern ) {
				if ( strpos($group, $pattern) !== false ) {
					$found = true;
					break 3;
				}
			}

			foreach ( $patterns as $tuple ) {
				$mask = $tuple[0];
				$pattern = $tuple[1];
				if (strpos($group, $pattern) !== false
				&& preg_match($mask, $group, $ret)) {
					$found = true;
					$email = $ret[1];
					$msg = str_replace("\r\n", " ", $group);
					$msg = preg_replace("/\s+/", " ", $msg);

					unset( $groups, $group );
					$response->move( $folder );
					echo "$date\t$email\t$msg\n";
					break 3;
				}
			}

			if ( !empty($mime["headers"][$xfr]) ) {
				foreach ( $google as $pattern ) {
					if ( strpos($group, $pattern) !== false ) {
						$found = true;
						$email = $mime["headers"][$xfr];
						$msg = str_replace("\r\n", " ", $group);
						$msg = preg_replace("/\s+/", " ", $msg);

						unset( $groups, $group );
						$response->move( $folder );
						echo "$date\t$email\t$msg\n";
						break 3;
					}
				}
			}
		}
	}

	if ( !$found && !empty($mime["headers"][$xfr]) ) {
		$email = $mime["headers"][$xfr];
		$response->move( $folder );
		echo "$date\t$email\t$xfr\n";
	}

	unset( $mime );
}

$db->commit( $dbname );

Spróbujmy uruchomić ten skrypt:


touch failures.csv
truncate -s 0 failures.csv

php failure.php INBOX INBOX.przetworzone >>failures.csv

Po zakończeniu działania skryptu znalezione zwrotki zostaną przeniesione do katalogu INBOX.przetworzone (musisz go najpierw utworzyć), a plik failures.csv będzie zawierać ich listę, po jednej zwrotce na linię.

Jeśli brakuje Ci czasu lub cierpliwości na składanie gotowego skryptu z powyższych części, możesz kupić całość już za 50 zł (zobacz cennik). Poza samym skryptem PHP dostaniesz również skrypt shell uruchamiający część PHP i odsiewający z pliku CSV ewentualne komunikaty ostrzegawcze, związane z nieprawidłową konfiguracją logowania błędów w PHP.

Całość jest przetestowana na Debianie Wheezy z PHP w domyślnej wersji 5.4.39, powinna jednak działać bez problemów w dowolnej wersji systemu Linux z zainstalowanym PHP.



Tomasz Klim
Administrator serwerów i baz danych, specjalista w zakresie bezpieczeństwa, architekt IT, przedsiębiorca. Prawie 20 lat w branży IT. Pracował dla największych i najbardziej wymagających firm, jak Grupa Allegro czy Wikia. Obecnie zajmuje się doradztwem dla klientów Fajne.IT, a w wolnych chwilach pisze artykuły. Chętnie podejmuje się ciekawych zleceń.


Wzbudziliśmy Twoje zainteresowanie?

Szukasz pomocy? formularz kontaktowy