Фильтрация почты в формате MailDir

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

Но, со временем, я подписался на кое-какое количество почтовых рассылок. Писем стало больше и появилось настойчивое желание их хоть как-то разделять, раскладывая по разным ящикам.

Я слышал про notmuch, однако теги и их фильтрация мне показались излишним усложнением, хотелось просто разложить яйца по корзинам. Слышал я и про procmail, однако он работает как фильтр с входящими сообщениями и не очень ложится ни на мой архив сообщений, ни на мою привычку получать почту через isync. В итоге, в одном из чатов, мне посоветовали посмотреть на mblaze, набор утилит для работы с MailDir. Хоть там и не было того, что прямо бы решало мою проблему, но утилиты были вполне себе удобными и превращали написание скрипта для фильтрации почты в тривиальное задание.

Формируем ТЗ

Перед тем, как начинать писать код давайте определим пару моментов. Само собой, хранить список правил в тексте программы мы не будем. Класть их куда-то ещё, в несвязанные с почтой места тоже не очень хочется. Я не придумал ничего лучше, чем хранить их прямо в корне почтовой директории, в файле .filter. Так как правила у меня будут максимально простые, то и формат файла будет прост. Пишем почту, сообщения от которой (или с которой) должны под правило попадать, и директорию, в которую нам эти письма нужно будет переложить.

Начало моего .filter выглядит вот так:

ports@openbsd.org ports-obsd
misc@openbsd.org misc-obsd
announce@openbsd.org announce-obsd

dev@suckless.org dev-suckless

Блоки отделяются пустой строкой для визуального удобства, никакого влияния на работу скрипта это не оказывает.

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

Я люблю набрасывать черновик справки ещё до написания кода, иногда это подсвечивает какие-то непродуманные моменты и позволяет лучше оценить удобство использования программы. Для сегодняшнего скрипта у меня вышло что-то такое:

mfilter [-hcp] [maildir]
   -h show this help
   -c filter old mails (by default we look at new)
   -p print all rules without filtering
You should specify maildir with -c and -p options. You can use only one flag at time.

Пишем код: приготовления, флаги и проверки

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

set -eu

Shell скрипты вещь хрупкая, и подверженная ошибкам. Чтобы хоть немного снизить вероятность их появления (и вероятность загаживания моей почтовой директории), давай включим пару опций. Первая, e, говорит, что если хоть одна команда в скрипте завершится с ошибкой, то мы с ошибкой завершим весь скрипт, продолжать его выполнение будет бессмысленно и опасно в любом случае. Это не касается команд, для которых мы явно проверяем код выполнения. Вторая, u, приводит к тому, что использование не инициализированной переменной приводит к ошибке, а учитывая e - к остановке выполнения всего скрипта. У нас будет пара моментов, где мы используем возможно не инициализированные переменные, и там нам будет нужно явно указать, что мы о такой возможности знаем.

По ходу написания скрипта я понял, что выводить помочь мне нужно в двух местах, при указании флага и при запуске без аргументов. Чтобы избежать дублирования я вынес вывод помощи и выход из скрипта в отдельную функцию.

help () {
    cat <<EOF
$0 [-hcp] [maildir]
   -h show this help
   -c filter old mails (by default we look at new)
   -p print all rules without filtering
You should specify maildir with -c and -p options. You can use only one flag at time.
EOF
    exit 0
}

Для того чтобы можно было удобно хранить и править текст справки я воспользуюсь heredoc. Это конструкция, которая позволяет встраивать данные прямо в шелл скрипт. Всё, от EOF до EOF, считается обычным текстом, который мы перенаправляем на ввод cat.

Теперь займемся аргументами нашей программы. Первым делом давайте обработаем тот случай, когда скрипт вызывается вовсе без аргументов. Обратите внимание, так как мы указали флаг u, то обращение просто к $1 приведет к ошибке, в случае если пользователь не указал аргумент при запуске. Чтобы этого избежать мы явно указываем, что если переменная пуста, то мы её заменим на пустую строку. -z здесь проверяет, что переданная ему в качестве аргумента строка имеет нулевую длину. Я очень плохо помню такие ключи проверки на память, к счастью, их всегда можно напомнить себе, если набрать man test.

if [ -z "${1:-}" ]; then
    help
fi

Далее мы обрабатываем флаги, полученные на вход и, на их основе, выставляем значения наших внутренних переменных.

case "$1" in
    "-h" )
	help
	;;
    "-p" )
	# если пользователь не хочет ничего фильтровать, то мы запомним это
	PRINT_ONLY=yes
	MAIL_DIR=${2:-}
	;;
    "-c" )
	# чтобы обработать старые почтовые сообщения, мы меняем флаг
	# фильтрации для программы mlist (о ней позднее) и поддиректорию,
	# в которую мы будем перекладывать файлы
	MAIL_DIR=$2
	MLIST_FILTER=-C
	MAIL_DEST=cur
	;;
    * )
	# если в первом аргументе не было ничего знакомого - будем считать
	# что это путь к maildir и мы хотим обработать только новые сообщения
	MAIL_DIR=$1
	MLIST_FILTER=-N
	MAIL_DEST=new
	;;
esac

Теперь мы сделаем две последние проверки: что пользователь указал maildir и что в этом самом maildir лежит файл с правилами переноса почты.

if [ -z "$MAIL_DIR" ]; then
    echo "You should specify maildir path"
    exit 1
fi

FILTER_FILE="$MAIL_DIR/.filter"
if [ ! -e "$FILTER_FILE" ]; then
    echo "You should have .filter file in maildir"
    exit 1
fi

Пишем код: основная рабочая логика

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

if [ -n "${PRINT_ONLY:-}" ]; then
	printf "%s\t-> %s\n" "$1" "$2"
else

Здесь первый аргумент - почтовый адрес, а второй - целевая директория, все точно так же, как в файле .filter. Я использую символ табуляции, так как большинство терминалов столкнувшись с ним поймет, что имеет дело с таблицей, и попробует хоть немного выровнять её строки.

Далее мы, в простом конвейере, вызываем три утилиты из mblaze и первая из них - mlist.

mlist "$MLIST_FILTER" "$MAIL_DIR/INBOX"

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

Теперь, когда мы получили список новых (или старых, если указан -c) почтовых сообщений, мы передаем его утилите mpick. Она позволяет нам осуществить более глубокую фильтрацию, на основе содержимого сообщения. Лично меня интересовали только отправитель или получатель, но в man mpick можно увидеть много других возможных правил.

mpick -t "\"To\" =~~ \"$1\" ||
	\"Cc\" =~~ \"$1\" ||
	\"From\" =~~ \"$1\""

Осталось просто переложить сообщения из одной директории в другую. Однако, даже для такого простого действия, я воспользуюсь отдельной утилитой, а именно mrefile. Дело в том, что у сообщений в maildir есть идентификатор, уникальный в пределах ящика, и если перекладывать почту без учета этого момента (как я, поначалу, и делал), можно напороться на Maildir error: duplicate UID при попытке запустить isync.

mrefile -v "$MAIL_DIR/$2"

Ключ -v мы указываем чтобы утилита напечатала новый путь для каждого сообщения. Эти пути мы передадим финальной программе в нашем конвейере - mv.

mrefile кладет все почтовые сообщения в директорию cur, даже если изначально они лежали в new. Это логично, ведь new указывает на то, что почтовое сообщение только пришло и не было никем обработано, а как раз его обработкой мы с помощью refile и занимались. Но для меня это было не самым удобным поведением. Я использую mutt для работы с почтой и в нем новые сообщения, из папки new, подсвечиваются как в списке самих сообщений, так и в боковой панели, где есть список всех моих почтовых ящиков. Сообщения, которые ещё не открывались, но уже лежат в cur, в терминологии mutt, считаются не новыми, а “старыми”. Они помечаются флагом O. Я смог настроить подсветку старых сообщений внутри ящика, но подсветить ящик со старыми сообщениями в боковой панели мне не удалось. Поэтому я просто двину все обработанные сообщения обратно в new.

xargs -r -n1 -I {} mv {} "$MAIL_DIR/$2/$MAIL_DEST"

mv не читает аргументы из стандартного ввода, поэтому нам потребуется использовать xargs. -r используется чтобы ничего не делать, если ничего не поступило на вход, -n1 говорит брать аргументы по одному, а -I {} задает паттерн ({}), который будет заменен на поступающие на стандартный вход аргументы. По сути, мы для каждого сообщения, которое mrefile переложила в cur, выполним команду

mv mail_file "$MAIL_DIR/$2/$MAIL_DEST"

Чтобы в новые сообщения не попали старые, попавшие под фильтр из-за флага -c, мы используем $MAIL_DEST. При указанном флаге мы переместим файл из cur в cur, в самого себя. Это не вызовет ошибок, так что я решил не писать здесь условие и не усложнять скрипт зря.

Наша функция готова и все что нам остается, это скормить ей набор правил из .filter.

while read -r line || [ -n "$line" ]; do
    if [ -n "$line" ]; then
	# shellcheck disable=SC2086
	move $line
    fi
done < "$FILTER_FILE"

Из-за особенностей shell мы делаем здесь пару неочевидных вещей. Например мы указываем флаг -r для read. Он нужен чтобы отключить фичу, позволяющую разбивать строку на две, с использованием \. Если read встретит такую строку, он сжует и слеш, и идущий следом символ переноса строки. Обычно это не то, что ожидает автор скрипта. Несмотря на то, что в нашем конкретном случае, появление слеша в конце строки не очень вероятно, лучше приучать себя ставить -r везде, где поедание слеша не является ожидаемой фичей.

Вторым удивительным приемом является двойное условие при чтении файла. Мы не просто проверяем какой код вернул read, мы ещё и смотрим, не осталось ли чего в $line. Связано это с тем, что в Unix любой файл должен оканчиваться переводом строки. Однако, ничего не запрещает не поставить его при ручном редактировании файла правил. read считает последнюю строку в переменную line, но не найдя переноса строки сделает вид, что файл кончился. Двойная проверка позволит нам этого избежать.

Последняя примечательная особенность скрипта это отключение проверки от shellcheck. Я стараюсь всегда проверять свои скрипты этим линтером, он указывает на многие неочевидные вещи, которые могут привести к проблемам при исполнении. В этом случае он жалуется на то, что $line не заключен в двойные кавычки. Обычно это хороший совет, все остальные случай использования переменных в скрипте используют кавычки, но если мы укажем их здесь, то на вход move поступит только один аргумент, зато с пробелом. Я же хочу чтобы пара “почта ящик” побилась пробелом на два отдельных аргумента. По хорошему, в данном случае стоит использовать массив или функцию, смотрите примеры в вики shellcheck, но в данном случае я нахожу это излишним и запутывающим код.

Вот и весь скрипт. Он небольшой и вызывается мной после каждой синхронизации почты. Читать рассылки стало куда удобнее, а предназначенная именно мне почта остается лежать в INBOX. Возможно, в дальнейшем мои сценарии использования почты станут сложнее и тогда больше смысла будет в использовании чего-то вроде notmuch, но сейчас мне достаточно и этого. Если вам интересна тема фильтрации и обработки почты из maildir - посмотрите на mblaze, там ещё много интересных утилит.

Исходный код целиком можно посмотреть в моем git.