Пишем свой сборщик писем на PHP (1 часть из 3)

В данной статье будет описано создание сборщика писем на PHP. Если в один прекрасный день у вас возникла необходимость забирать письма с почтового (почтовых) ящиков и куда-то складывать (скорее всего, в базу данных), то добро пожаловать. Очень надеюсь, что статья вам пригодиться и облегчит жизнь. В третьей части статьи будут приложены все необходимые коды. И так поехали…

Сначала определимся с требованиями:
1) Реализация на PHP
2) Чтение писем по протоколу IMAP
3) Запись содержимого писем в базу MySQL
4) Сохранение вложений к письмам в файловой системе
5) Будет реализовано два скрипта: первый проверяет новые письма, а второй закачивает письма
6) Будет таблица, содержащая список почтовых ящик, с которых собираются письма
7) Также будет сохраняться информация о том: от кого письмо, кому письмо, кому переадресовано, кто в копии и кто в скрытой копии

Почему именно такой выбор?
Письма можно читать с почтового сервера по протоколам IMAP и POP3.
Реализация сборщика будет построена с использованием протокола IMAP, т.к. он имеет ряд преимуществ. Не знаю как в реализациях POP3 на других языках, но в PHP мне не удалось получить UID письма, можно получить только порядковый номер письма среди новых. Также минусом при использовании POP3 является то, что письма нужно считывать с первого раза. А у нас будет другая реализация: письма сперва проверяются, а только потом закачиваются, что возможно благодаря IMAP.
Вложения к письмам будем хранить в файловой системе, а в базе данных будут храниться только пути к этим файлам (хотя вам никто не запрещает записывать файлы в базу данных)

ПРОЕКТИРОВАНИЕ ТАБЛИЦ
Таблица почтовых ящиков
Создадим в MySQL новую базу данных mail_collector с кодировкой UTF-8 по умолчанию:

CREATE DATABASE mail_collector DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci;

Создадим таблицу mailboxes, которая будет хранить необходимую информацию для подключения к почтовому ящику.

CREATE TABLE mailboxes
(
	id int PRIMARY KEY NOT NULL AUTO_INCREMENT,
	email varchar(50) NOT NULL,
	password varchar(50) NOT NULL, 
	host varchar(50) NOT NULL, 
	port varchar(50) NOT NULL, 	
	is_ssl bit NOT NULL,   
	is_deleted bit NOT NULL,
	last_message_uid int NOT NULL	
);

Назначение полей:
email — почтовый ящик
password — пароль от ящика
host — адрес почтового сервера. Например, imap.gmail.com или 173.194.71.108
port — порт, по которому работает почтовый сервер
is_ssl — если флаг установлен, то подключение будет по SSL
is_deleted — если флаг установлен, то почтовый ящик считается удаленным и не участвует в сборке писем
last_message_uid — поле используется для хранения UID последнего считанного сообщения

Заполним таблицу mailboxes данными трех почтовых ящиков, созданных на разных почтовых серверах: gmail, yandex и mail.ru. В качестве примера к почтовым ящикам на gmail и yandex будем подключаться через SSL, а на mail.ru — без SSL.

INSERT INTO mailboxes(email,password,host,port,is_ssl,is_deleted) VALUES
('mail.collector.kz@gmail.com','mail.collector','imap.gmail.com','993',1,0,0),
('mail.collector.kz@mail.ru','mail.collector','imap.mail.ru','143',0,0,0),
('mail.collector.kz@yandex.ru','mail.collector','imap.yandex.ru','993',1,0,0)

Таблица сообщений
Создадим таблицу messages для хранения сообщений:

CREATE TABLE messages
(
	id int PRIMARY KEY NOT NULL AUTO_INCREMENT,
	mailbox_id int NOT NULL,
	uid int NOT NULL,
	subject varchar(255),
	body_text text,
	body_html text,
	attachment_count int,
	header text,
	message_date datetime,
	create_date datetime NOT NULL,
	modify_date datetime,	
	is_ready bit NOT NULL 	
);

Так же создадим внешний ключ на таблицу mailboxes:

ALTER TABLE messages ADD CONSTRAINT fk_messages_user_id 
FOREIGN KEY (mailbox_id) REFERENCES mailboxes(id);

Назначение полей:
mailbox_id — ссылка на почтовый ящик, к которому относится письмо
uid — уникальный номер письма в почтовом ящике
subject — тема письма
body_text и body_html — письмо храниться на сервере в виде обычного текста и html-версии
attachment_count — кол-во вложений письма
header — технический заголовок письма
message_date — дата письма
create_date — дата создания записи
modify_date — дата изменения записи
is_ready — письмо полностью загружено

Таблица адресов
Создадим таблицу addresses для хранения адресов: адрес отправителя, адреса получателей, адреса получателей скрытой копии и т.д.

CREATE TABLE addresses
(
	id int PRIMARY KEY NOT NULL AUTO_INCREMENT,
	message_id int NOT NULL,
	type varchar(10) NOT NULL,
	email varchar(50) NOT NULL
);

И создадим внешний ключ для связи с таблицей messages:

ALTER TABLE addresses ADD CONSTRAINT fk_addresses_message_id 
FOREIGN KEY (message_id) REFERENCES messages(id);

Назначение полей:
message_id — ссылка на сообщение, к которому относится данный адрес
type — тип адресата: «from» (отправитель), «to»(получатель), «cc» (скрытая копия) и т.д.
email — адрес электронной почты
Таблица вложений
Создадим таблицу attachments для хранения информации о файлах прикрепленных к письму:

CREATE TABLE attachments
(
	id int PRIMARY KEY NOT NULL AUTO_INCREMENT,
	message_id int NOT NULL,
	file_name varchar(255) NOT NULL,
	mime_type varchar(255) NOT NULL,
	file_size int NOT NULL,
	location varchar(255) NOT NULL	
);

Создадим внешний ключ для связи с таблицей messages:

ALTER TABLE attachments ADD CONSTRAINT fk_attachments_message_id 
FOREIGN KEY (message_id) REFERENCES messages(id);

Назначение полей:
message_id — привязка к конкретному сообщению
file_name — имя файла
mime_type — тип данных: «application/zip», «application/pdf», «image/png» и т.д.
file_size — размер файла в байтах
location — расположение файла

СКРИПТЫ
Описание работы: первый скрипт подключается к почтовому серверу, записывает в базу данных UID-ы новых писем, а второй скрипт, который будет рассмотрен позднее, по UID писем закачивает содержимое писем с вложениями и другой информацией.
Скрипт для чтения новых писем (reader.php)

<?php
require_once "func.php";//подключаем функции
require_once "connect.php";//подключаем настройки к MySQL
 
wr("\n=============================\n");
 
//отключаем ограничение по времени испольнения
//по умолчанию тайм-аут 60 секунд
set_time_limit(0);
 
//предполагается, что данный скрипт будет запускаться планировщиком
//через равные промежутки времени. Например, каждые 30 секунд.
//Но может возникнуть ситуация, что предыдущий может не успеть завершить
//свою работу. Для решения этой проблемы, когда скрипт запускается он
//накладывает блокировку на заданный нами файл, совершив свою работу
//скрипт снимает блокировку с файла и удаляет его. В это время, если по
//расписанию сработал еще один запуск скрипта, то он увидит блокировку и
//завершиться ничего не делая
 
//reader.lock - файл, который мы будем использовать для блокировки.
//Файл блокировки будем храниться рядом со скриптом.
//Можно было бы хранить во временной директории, но на хостинге
//может быть ограничен доступ к временной директории
$lock = "reader.lock";
 
//логическая переменная $aborted будет сигнализировать о том, что
//предыдущий скрипт был прерван. Для этого проверяем существование
//файла, а затем пытается получить время последней модификации файла
//Если файл блокировки существует и удалось получить время последнего
//изменения файла, то значит предыдущий скрипт был прерван	
$aborted = file_exists($lock) ? filemtime($lock) : false;
 
$fp = fopen($lock,'w');//открывает файл с возможностью записи
 
//регистрируем функцию, которая выполниться по завершению работы скрипта
register_shutdown_function(function() use ($fp, $lock) {
	wr("shutdown");
	flock($fp, LOCK_UN);//снимаем блокировку с файла
	fclose($fp);//закрываем файл
	unlink( __DIR__ . DIRECTORY_SEPARATOR . $lock);//удаляем файл
});
 
//пытаемся установить на файл блокировку на запись (LOCK_EX),
//так же передаем флаг LOCK_NB, чтобы скрипт не ждал освобождения
//блокировки
if(!flock($fp,LOCK_EX|LOCK_NB)){
	//если не удалось наложить блокировку, то значит предыдущий 
	//скрипт еще работает.
	wr("busy\n");//пишем в лог, что занято
}else{
 
	if($aborted){
		//если выполнение предыдущего скрипта было прервано,
		//то в нашем случае просто пишем в лог, что прервано. 
		wr("Aborted\n");
	}
 
	//отключаем Autocommit, будем сами управлять транзакциями
	mysql_query("SET AUTOCOMMIT=0");
	mysql_query("START TRANSACTION");//стартуем транзакцию
 
	//запрос из базы списка действующих почтовых ящиков
	$sql = "SELECT * FROM mailboxes WHERE is_deleted = false";
	$res = mysql_query($sql);
 
	//перебираем почтовые ящики
	while($row = mysql_fetch_array($res)){
		$mailbox_id = $row['id'];
		$host = $row['host'];//адрес почтового сервера
		$port = $row['port'];//порт почтового сервера
		$user = $row['email'];//имя пользователя (почтовый ящик)
		$password = $row['password'];//пароль к почтовому ящику
		$last_uid = $row['last_message_uid'];//uid последнего считанного сообщения
		//если подключение идет через SSL, 
		//то достаточно добавить "/ssl" к строке подключения, и
		//поддержка SSL будет включена
		$ssl = $row['is_ssl'] ? "/ssl" : "";
 
		//строка подключения
		$conn = "{{$host}:{$port}{$ssl}}";
		wr("Read $user, conn = $conn");
 
		//открываем IMAP-поток
		$mail = imap_open($conn,$user,$password);
		if(!$mail){		
			//пишем влог сообщение о неудачной попытке подключения
			wr("Error opening IMAP. " . imap_last_error());
			continue;//переходим к следующему ящику
		}
 
		//Считываем только письма, у которых UID больше, 
		//чем UID последнего считанного сообщения. Для этого воспользуемся 
		//функцией imap_fetch_overview, у которой первый параметр - IMAP поток,
		//второй параметр - диапазон номеров и третий параметр - константа FT_UID.
		//FT_UID говорит о том, что диапазоны задаются UID-ами, иначе порядковыми
		//номерами сообщений. Здесь важно понять разницу. 
		//Порядковый номер письма показывает номер писема среди писем почтового ящика,
		//но если кол-во писем уменьшить, то порядковый номер может измениться.
		//UID письма - это уникальный номер письма, также присваивается по попрядку, 
		//но не изменяется.
 
		//Сейчас и в дальнейшем мы же будем полагаться только на UID писем.
		//Диапазаны можно задать следующим образом:
		//"2,4:6" - что соответствует UID-ам 2,4,5,6
		//"7:10" - соответствует 7,8,9,10
		//В нашем случае для удобста будем брать диапазон от последнего UID + 1
		//и до 2147483647.
		$uid_from = $last_uid + 1;
		$uid_to = 2147483647;		
		$range = "$uid_from:$uid_to";		
		$arr = imap_fetch_overview($mail,$range,FT_UID);		
		$message_uid = -1;
		//перебираем сообщения
		foreach($arr as $obj){
			//получаем UID сообщения
			$message_uid = $obj->uid;
			wr("add message $message_uid");
 
			//создаем запись в таблице messages,
			//тем самым поставив сообщение в очередь на загрузку
			$sql = "INSERT INTO messages(mailbox_id,uid,create_date,is_ready)
					VALUES($mailbox_id,$message_uid,now(),0)";
			mysql_query($sql) or die(mysql_error());
		}
 
		if($message_uid != - 1){
			wr("last message uid = $message_uid");
 
			//если появились новые сообщения, 
			//то сохраняем UID последнего сообщения
			$sql = "UPDATE mailboxes 
					SET last_message_uid = $message_uid
					WHERE id = $mailbox_id";
			mysql_query($sql) or die(mysql_error());
		}else{
			//нет новых сообщений
			wr("no new messages");
		}
	}
 
	mysql_query("COMMIT");//завершаем транзакцию
 
	//закрываем IMAP-поток
	imap_close($mail);
}
?>

3 комментария

  1. Алексей:

    Если у письма отправитель в виде ivanov@mail.ru, то после imap_fetch_overview у объектов в свойстве [from] только Иванов, без ivanov@mail.ru. Как сделать так, чтобы в [from] был адрес почты?

  2. Алексей:

    *[Иванов]ivanov@mail.ru
    в предыдущем сообщении обрезались угловые скобки, там была фамилия в угловых скобках, заменил чтобы показать.

  3. Сергей:

    Проверяю на mail.ru на чистом ящике, всегда uid == msgno (т.е. 1, 2 … количество писем). Т.е. если письма удалять, то uid всегда будет сбрасываться в 1? Это такая особенность mail.ru или всегда так?
    Как же тогда только новые письма забирать, если ящик периодически чистят?

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *