Скрипт поиска файлов-писем (почтовых сообщений) по полю “Subject” (“Тема”)

Этот скрипт позволяет выполнять поиск файлов (представляющих из себя почтовые сообщения), осуществляя отбор и удаление файлов, чье поле Subject соответствует заданному критерию отбора (маске).

Мотивацией для написания скрипта послужила следующая ситуация: в некой тридевятой организации жил-был почтовый сервер MDaemon,  с достаточным количеством почтовых ящиков. Сисадмины этой организации по каким-то чуждым мне религиозным соображениям не использовали квотирование дискового пространства, выделяемого под каждый почтовый ящик (хотя MDaemon имеет такую возможность). В результате, они (админы) время от времени  «хэвали фан» с тем, что их почтовый сервер отказывался выполнять свои прямые обязанности: принимать и отправлять почту. В этом случае они начинали судорожно чистить почтовые ящики пользователей от всякого хлама, коего в них чуть более чем до хрена. В первую очередь удалению подлежали многочисленные типовые сообщения, которые посылались в группы рассылки, например: поздравления с праздниками, днями рождениями и прочие аналогичные сообщения, которые теряют свою актуальность сразу после прочтения. В качестве критерия поиска сообщений, подлежащих удалению, было бы очень удобно использовать то, что все эти типовые  сообщения имеют типовой же набор слов в теме сообщения, по которому их можно было бы легко отфильтровать, НО… НО дело в том, что, к большому сожалению, MDaemon не имеет инструментов, которые позволили бы выполнить эту задачку. Вернее будет сказать, что такой инструмент есть (это «Менеджер очередей и статистики»), но использовать его не получится, если текст в поле Subject был закодирован при помощи Quoted-printable или Base64 алгоритмов, т.к. этот самый  «Менеджер очередей и статистики» отображает содержимое поля  Subject в «сыром» виде (не декодируя текст). В результате, если в теме письма была использована кириллица, то вместо красивой картинки, которую вы можете видеть на странице с документацией по вышеприведенной ссылке

вы увидите что-то типа следующего

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

MDaemon имеет очень простую структуру хранилища для почтовых сообщений: каждому почтовому ящику соответствует директория на жестком диске. Каждой папке («Входящие», «Отправленные» и т.п.) внутри почтового ящика соответствует поддиректория  внутри корневой директории почтового ящика. Каждому письму в почтовом ящике соответствует файл с расширением msg в корневой директории этого почтового ящика или в одной из его поддиректорий. Такая структура хранилища (в отличие от базы данных, которая, например, используется в MSExchange), с одной стороны, проста и прозрачна, позволяет легко выполнять backup на уровне сервера почтового ящика, папок внутри ящика или даже индивидуальных сообщений средствами самой операционной системы. Но,  с другой стороны, понятно, что такое хранилище будет проигрывать как по быстродействию, предоставляемым сервисам по работе с сообщениями, так и по оптимизации самого хранилища. Так, например, если письмо, отправленное в группу рассылки, на MSExchange будет храниться в БД Exchange в единственном экземпляре, то в случае с MDaemon для каждого почтового ящика будет создана и сохранена своя копия почтового сообщения. Именно поэтому хотелось бы избавиться в первую очередь от тех «мусорных» почтовых сообщений, которые были направлены в адрес групп рассылки.

И так, алгоритм задачи достаточно простой: 

  • в скрипт передается полный путь к папке на диске, в  которой хранятся почтовые сообщения в виде файлов, и маска (регулярное выражение) для фильтрации сообщений.
  • скрипт рекурсивно считывает все файлы с сообщением *.msg, находит в них поле Subject и, если необходимо, декодирует его, приводя к состоянию пригодному для последующей обработки
  • производится фильтрация  писем, на основании переданной в скрипт маски и удаление отфильтрованных писем

 

Первая неожиданность, с которой мне пришлось столкнуться во время написания скрипта, – это то, что в PowerShell отсутствует встроенная возможность организовать чтение файла строка за строкой. Да, есть в PowerShell командлет Get-Content, который позволяет считать весь файл почтового сообщения целиком в переменную, а затем обращаться к файлу, как массиву строк. Но в данном конкретном случае этот вариант, мягко говоря, не оптимален. Дело в том, что поле Subject  является частью заголовка письма и находится в начале файла. Если письмо имеет большой объем, то мы вынуждены будем тратить память на хранение большого объема информации, которая нам заведомо не нужна. Кроме того, чтение и обработка большого объема заведомо ненужных  данных не лучшим образом скажется и на  быстродействии скрипта. Частично эту проблему можно было бы решить используя конвейер dir …| select-string “^Subject:.+” –List, тем самым прекращая поиск после первого совпадения, но и этот вариант не годится. Дело в том, что поле Subject может состоять из нескольких строк, причем заранее неизвестно из какого количества строк состоит это поле. Согласно rfc822 Поле Subject –это неструктурированное поле, которое может состоять из нескольких строк. Первая строка этого поля начинается со слова “Subject:”, а вторая (и все последующие) с пробела или табуляции. Поэтому после того, как мы найдем строку, начинающуюся со слова “Subject:”, нам необходимо продолжать построчно считывать файл, проверяя первый байт каждой строки. Если очередная строка не начинается с символа пробел или табуляция, то это означает, что все строки принадлежащие полю Subject нами уже найдены, и работу с текущим файлом можно прекратить и перейти к следующему. Посему, очень хотелось бы использовать классическое построчное чтение файла. Слава богу, что таковая возможность имеет место быть в .Net, а это значит, что мы легко можем использовать эту возможность в скрипте на PoSh. Для того чтобы организовать «классическое»  чтение файла, можно использовать следующую последовательность команд:

#Открываем файл при помощи .Net
$f = [System.IO.File]::OpenText($FullFileName)
#Построчно читаем файл в цикле
while (!$f.EndOfStream) {
$line = $f.ReadLine()
...
}
#Зарываем файл
$f.Close()

И так, с чтением файла – разобрались. Что на очереди? Необходимо выделить из строки поля Subject текст, закодированный при помощи Quoted-printable или Base64, а потом декодировать этот текст.

Я исходил из того, что строка поля subject может иметь такой вид:

[Text1]<D1><CodePage><D2><CodeType><D2><CodedText><D3>[Text2]

где

[Text]            – некодированный текст

<D>               – символы разделители

<CodePage>        – кодовая страница закодированного текста

<CodeType>        – флаг типа кода (B – Base64, Q – QuotedPrintable)

<CodedText>       – закодированный текст

(в квадратных скобках указана необязательная часть строки, которая может отсутствовать в строке поля Subject)

например: ORG_customers =?koi8-r?B?8yDEzsXNINLP1sTFzsnRIQ==?= {01}

или  

=?koi8-r?Q?Re=3A_2_=C9=CE=D3=D4=D2=D5=CB=C3=C9=C9?=

Для разбора строки поля Subject я использую вот такое регулярное выражение:

\s*(.*)(?==\?[^=])=\?(.+?)\?([BQ])\?(.+)\?=(?<=\?=)(.*)

(см. функцию DecodeSubjString)

\s*              – в начале строки могут идти пробелы, которые нас совсем не интересуют

(.*)(?==\?[^=]) – этот Positive Lookahead находит и сохраняет при помощи группы захвата любые символы, за которыми следует символы «=» и «?», но после которых не следует символ «=». При этом сами символы “=”,”?”,”[^=]” не захватываются группой захвата.

=\?               – символы разделители “=” и “?”

(.+?)             – нежадная группа захвата, которая захватит и сохранит для нас все символы, которые расположены между символами разделителями

\?               – символ разделитель “?”

([BQ])           – захватываем флаг типа кода: символ “B” или “Q”

(.+)             –      захватим закодированный текст

\?=              –      символы разделители “?” и “=”

(?<=\?=)(.*)     – очередной Lookaround (на этот раз Positive Lookbehind, который захватит для нас все символы, перед которыми стоят сиволы “=” и “?”

И так, мы выделили при помощи регулярного выражения из строки поля subject всю необходимую для декодирования строки информацию. Осталось декодировать текст. Здесь нам на помощь приходит .Net:

при помощи статического метода мы можем выполнить преобразование кода символа ($charcode) в символ указанной кодовой страницы текста ($Codepage)

[System.Text.Encoding]::GetEncoding($CodePage).GetString($charcode)

На самом деле GetString может принимать в качестве входного параметра не только код одного символа, но массив таковых кодов. И, в случае с кодом Base64, мы этим воспользуемся. Для получения массива кодов символов (из закодированного при помощи Base64 текста) мы воспользуемся статическим методом

[System.Convert]::FromBase64String($EncodedString)

К сожалению, декодировщик для Quoted-printable в .Net отсутствует (или я его не нашел ;)), а, значит, придется его написать его самому. Подробно том, что есть Quoted-printable и как с ним бороться, можно прочитать в rfc1521.  Я же приведу лишь небольшой пример

Давайте взглянем строку из предыдущего примера: Re=3A_2_=C9=CE=D3=D4=D2=D5=CB=C3=C9=C9

Символы с кодами от 33 до 60 (включительно) и  от 62 до 126 (включительно) могут быть представлены  ASCII симовлами, которые соответствуют этим кодам. В данном выражении – это символы “Re

Пробелы могут быть заменеы на сивол «подчеркивания» (“_”)

Все симолы, чьи коды больше, чем 127 заменяются на шестнадцатеричный код символа, перед которым стоит знак “=”: =C9

Для декодирования Quoted-printable  можно снова воспользоваться регулярным выражением. У меня оно получилось таким:

(?:=([0-9A-F]{2,2}))|([^=]+)

При помощи этого regexp’а мы захватываем

(?:=([0-9A-F]{2,2}))    – либо два символа, представляющие из себя Hex-code символа (перед которыми стоит знак “=”),

([^=]+)                 – либо некодированный символ (тот, чей код меньше 127)

Разобрав quoted-printable строчку на составлющие, остается заменить коды символов на сами символы в соответствии с заданой кодовой страницей, а так же замеить символы “_” (подчеркивание) на “ ” (пробел)

И так, вот, конечный результат:

####################################################################################################################
#
# GetMailsSubject.ps1 PowerShell shs 20100325
#
####################################################################################################################
#
#.Назначение
#	Декодирование поля Subject почтовых сообщений (файлов) в заданной папке и вложенных подпапках.
#	Опционально - удаление почтовых сообщений (файлов) согласно заданной маске для поля Subject
#
#.Параметры
#
#	.\GetMailsSubject.ps1 <Path2Dir> [<MaskOfSubj>]
#
#	Path2Dir 	- путь к папке, в которой будет происходить поиск почтовых сообщений (файлов с расширением *.msg)
#
#	MaskOfSubj	- маска-регулярное выражение. Все файлы почтовых сообщений, чье декодированое поле Subject
#				  соответствует заданой маске, будут удалены.
#
#.Описание
#	Скрипт считывает все файлы *.msg в заданной папке и подпапках и ищет в них строки,
#	из которых состоит поле Subject. Если в этих строках имеется текст, закодированный при помощи Quoted-printable
#	или Base64, то декадирует его и реконструирует текст поля Subject.
#	Скрипт должен быть запущен, по крайней мере, с одним обязательным параметром, содержащим путь к папке, в которой будет
#	происходить поисх файлов-сообщений. Второй параметр запуска - необязатьельный. Если второй параметр запуска не указан,
#	то скрипт просто выводит на экран декодированный текст поля Subject для каждого сообщения (файла), иначе скрипт будет
#	выводить на экран содержимое поля Subject, которое соответствует заданной этим параметром маске.
#
#	Все файлы, чье  декодированое содержимое поля Subject соответствует указанной маске, будут удалены!!!
#
#
#
####################################################################################################################
#Параметры вызова скрипта
#
param ($Path2Dir=$(throw "Укажите путь к папке с письмами в качестве параметра запуска"), $MaskOfSubj)
#
#===================================================================================================================
#					Функция декодирования Quoted-printable|Base64 строки
#
#  $CodePage 		- наименование текстовой кодовой страницы
#  $EncodingMethot	- флаг метода кодирования: Q|B (Q - quoted printable, B - base 64)
#  $EncodedString	- строка, подлежащая декодированию
#=
function DecodeQPB64 ($CodePage, $EncodingMethod, $EncodedString){
	$DecodedString=""
	#Декодируем Base64
	if ($EncodingMethod -eq "B") {
		$DecodedString=[System.Text.Encoding]::GetEncoding($CodePage).GetString([System.Convert]::FromBase64String($EncodedString))
	}
	#Декодируем Quoted-printable
	if ($EncodingMethod -eq "Q") {
		[regex]$regex="(?:=([0-9A-F]{2,2}))|([^=]+)"
		$match=$regex.match($EncodedString)
		#
		$DecodedString=""
		while ($match.Success) {
		$charcode=$match.Groups[1].value
		$chars=$match.Groups[2].value
		if ($charcode) {
   			$charcode=[int]("0x"+$charcode)
   			$DecodedString+= [System.Text.Encoding]::GetEncoding($CodePage).GetString($charcode)
		}
		if ($chars) {
			$DecodedString+=($chars -replace "_", " ")
		}
		$match=$match.NextMatch()
		}
	}
	$DecodedString
}
#
#=======================================================================================================================
#=
#								 Функция декодирования поля Subject
#=
function DecodeSubjString ($SubjString) {
	#Если строка содержит кодированную в Base64 или Quoted-printable подстроку,
	#то разберем эту строчку при помощи regex и, затем, декодируем.
	if ($SubjString -match "\s*(.*)(?==\?[^=])=\?(.+?)\?([BQ])\?(.+)\?=(?<=\?=)(.*)" ) {
		#
		#Реузультаты разбора строки при помощи regexp:
		#$Matches[1] - некодированные символы, которые находятся до начала кодированного текста ("prefix")
		#$Matches[2] - наименование языковой кодовой страницы, использованной  в троке
		#$Matches[3] - метод кодирования (Q - quoted printable, B - Base64)
		#$Matches[4] - закодированная строка
		#$Matches[5] - некодированные символы, которые находятся после кодированного текста ("suffix")
		#
		#Отправляем закодированную часть строки на перекодировку
		$Decoded = DecodeQPB64 $Matches[2] $Matches[3] $Matches[4]
		#Строка поля Subject может содержать, как кодированную часть, так и некодированные,
		#Соберем строку Subject воедино, вернув на место некодированные части,
		#отрезанные на этапе рабора строки при помощи regexp
		$DecodedSubjString = $Matches[1] + $Decoded + $Matches[5]
	}
	#Иначе, декодировать нечего - выводим as is
	else {
		$DecodedSubjString = $SubjString
	}
	## Возвращаем результат работы функции
	$DecodedSubjString
	#
}
##
#=================================================================================================================
# 															Начало скрипта
#
#$VerbosePreference = "Continue" #"SilentlyContinue" #Continue
#Очистим экран
cls
if (Test-Path $Path2Dir) {
	#Поиск писем во всех папках и подпапках
	dir "$Path2Dir" *.msg -recurse|
	foreach {
		Write-Verbose "====="
		#Выделяем полный путь файлу из свойства PSPath, содержащего значения вида
		#Microsoft.PowerShell.Core\FileSystem::C:\_tmp\0\ZabavnovM\&BCcENQRABD0EPgQyBDgEOgQ4-.IMAP\md50000000008.msg
		#В результате этой строки кода, переменная $Matches[1] будет содержать текст,
		#попавший в группу захвата регулярного выражения и
		#представляющий из  себя полный путь к файлу.
		$_.PSPath -match ".+::(.+)" |Out-Null
		$FullFileName = $Matches[1]
		#Все операции с файлом выполняем при помощи .Net
		#Открываем файл при помощи .Net
		$f = [System.IO.File]::OpenText($FullFileName)
		#Признак конца поля Subject выставляем в false
		$EndOfSubj=$false
		#Читаем файл строка за строкой до тех пор, пока не достигнем конца файла или конца поля Subject
		while ((!$f.EndOfStream) -and (!$EndOfSubj)) {
			$line = $f.ReadLine()
			#Если найдена первая строка поля Subject...
			if ($line -match "^Subject:(.+)") {
				Write-Verbose $line
				#Декодируем часть строки попавшей в группу захвата (ту часть, что шла после "Subject:")
				$Subject = DecodeSubjString $Matches[1]
				#Продолжаем построчное чтение файла, пока не достигнут конец файла или конец поля Subject
				While ((!$f.EndOfStream) -and (!$EndOfSubj)) {
					$line = $f.ReadLine()
					#Если очередная считаная из файла строка начинается с символа пробел или табуляция,
					#значит эта строка является продолжением поля Subject...
					if ($line -match "^\s(.+)") {
						Write-Verbose $line
						$Subject += DecodeSubjString $line
					}
					#...иначе мы достигли конца поля Subject и чтение файла нужно прекратить
					else {
						$EndOfSubj=$true
					}
				}
			}
		}
		#закрываем файл
		$f.Close()
		#
		#Окончательная обработка
		#Если при запуске скрипта был указан второй параметр запуска, то
		#его значение используется в качестве regexp для отбора и удаления файлов
		#Будут удалены все файлы (письма), у которых тема письма удовлетворяет заданной маске.
		if ($MaskOfSubj) {
			if ($Subject -match $MaskOfSubj) {
				$Subject
				del $FullFileName -Force -WhatIf
			}
		}
		else {
			#Выводим результат
			$Subject
		}
	}
}

Upd Оказывается, что я не совсем верно понимал то, как работает конвейер в PowerShell :( , и из-за этого допустил ряд ошибочных утверждений (см. комменты). Но Василий благородно указал мне на мои ошибки  ;) Обещаю найти время и, проведя работу над ошибками, выложить новую версию скрипта полностью соответствющую PoSh-style ;)

Upd 2 На всякий случай напишу: скрипт рабочий и ошибок не содержит. Ошибочны только мои предположения о невозможности использования в рамках этого скрипта командлета Get-content

13 Comments

  1. Наговариваешь ты на PowerShell :)
    Get-Content читает файл как раз построчно, а память ты займешь если присвоишь все строки файла в переменную:
    $Text = Get-Content file.txt #gc прочитает по очереди все строки файла, и засунет их в переменную.
    Если ты хочешь обрабатывать строки по очереди, не подгружая все в память, можно использовать конвейер:
    Get-Content file.txt | foreach { обрабатываем }
    Можно еще задать количество читаемых строк параметром -TotalCount, а -ReadCount – задает сколько строк читать за раз.
    Впрочем в твоем случае, больше всего подойдет конструкция switch, она позволяет и читать файл построчно, и использовать регулярные выражения для обработки каждой строки:
    switch -regex -file file.txt
    {
    ‘регулярное выражение’ {выполняемый код}
    ‘регулярное выражение’ {выполняемый код}
    default {код выполняемый если ни одно из выражений не совпало}
    }

    • > Get-Content читает файл как раз построчно, а память ты займешь если присвоишь все строки файла в переменную
      именно это (загнать результат чтения в переменную) я собирался сделать ;)

      Нет, мне НЕ подойдет switch, т.к. он не позволят прекращать чтение файла динамически (при наступлении некоего условия) и, в результате, switch будет в холостую парсить большую часть файла, после того, как все строки поля Subject были уже найдены.

      -TotalCount использовать не получится, т.к. не известно когда закончится поле Subject (это определяется динамически, во время чтения файла). Можно, конечно, предположить, что заголовок письма будет занимать не больше, чем, например, 30 строк. Но это не православно.

      • >именно это (загнать результат чтения в переменную) я собирался сделать ;)
        А вот как раз не надо было :)
        >Нет, мне подойдет switch, т.к. он не позволят мне прекращать чтение файла динамически
        Почему это? Просто break вызови. То же самое в foreach кстати.

        • >Почему это? Просто break вызови. То же самое в foreach кстати.
          я исхожу из того, что break прекратит обработку текущей строки в switch, но не сможет повлиять на своего поставщика строк в конвейере (get-content), который продолжит построчное чтение файла и передачу считаных строк в конвейер.

          • Неправильно ты исходишь. Конвейер выполняется последовательно:
            1. Get-Content читает первую строчку, передаёт её дальше.
            2. Foreach-Object получает первую строчку, обрабатывает её.
            3. Get-Content читает вторую строчку, передаёт её дальше.
            4. Foreach-Object получает вторую строчку, вызывается break.
            5. Конвейер останавливается. Всё.

            А в switch вообще не используется Get-Content.

          • я думал, что Get-content обязательно должен считать весь файл целиком, вне зависимости от того, что произойдет с данными, которые он передает в конвейер.

            Но в любом случае, скрипт рабочий, ща попробу написать конвейер вместо while ((!$f.EndOfStream) -and (!$EndOfSubj))

          • >я думал, что Get-content обязательно должен считать весь файл целиком
            С чего ты такое мог подумать вообще? :)
            (Get-Help Get-Content).description
            It reads the content one line at a time and returns an object for each line.

          • видимо у меня конвейеры плохо в голове укладываются ;)
            с традиционным программированием мне как-то проще.

            ну, да, читает get-content построчно и передает в конвейр построчно. Но, если мы прекратили обработку где-то дальше, по конвейеру, то не приведет ли это к тому, что get-content будет продолжать построчное чтение и передавать считаное дальше, просто эти данные будут отбрасываться?

          • Нет, не приведет.
            Дело в том что командлеты – суть классы .NET. В конвейере они работают совместно, и как следствие открываются уровни взаимодействия которые и не снились консольным утилитам. По сути происходит совместная работа двух классов в программе.

            Для проверки, можно воспользоваться следующей конструкцией:
            get-content test.txt |
            %{write-host “First %: $_”; $_} |
            %{write-host “Second %: $_”; if ($_ -like “#*”){break}}

            Когда будет вызван break во втором foreach, остановит свою работу и первый, и аналогично get-content.

          • Спасибо, обязательно проверю ;)

            PS Но подозреваю, что все кончится словами
            “Василий, вы, как всегда, правы” (с) All ;)

  2. Pingback: Скрипт поиска файлов-писем (почтовых сообщений) по полю “Subject” (“Тема”). Дубль два или работа над ошибками « ShS's Blog

Leave a Reply

Your email address will not be published. Required fields are marked *

Notify me of followup comments via e-mail. You can also subscribe without commenting.