commit 3884cebf5460bcd05c1fc89688a7be851508223c
parent 54c093fc6f7a6d831d77c5000288fc2a4b840457
Author: Anton Konyahin <me@konyahin.xyz>
Date: Fri, 5 May 2023 11:15:52 +0300
post: mail filtering
Diffstat:
1 file changed, 331 insertions(+), 0 deletions(-)
diff --git a/content/blog/mail-filtering.md b/content/blog/mail-filtering.md
@@ -0,0 +1,331 @@
+---
+title: "Фильтрация почты в формате MailDir"
+date: 2023-04-09T20:24:25+03:00
+---
+
+Я использую почту в формате MailDir. Мне кажется удобным держать весь
+свой архив сообщений локально, в виде директории с файлами, мне удобно
+его бэкапить и переносить с одного компьютера на другой. У меня всегда
+был не очень большой поток сообщений, рабочая почта жила отдельно, в
+своем огороженном корпоративном мирке, так что я получал письма либо
+от рекрутеров (которые хотели меня купить), либо от сайтов (которые
+хотели мне что-то продать).
+
+Но, со временем, я подписался на кое-какое количество почтовых
+рассылок. Писем стало больше и появилось настойчивое желание их хоть
+как-то разделять, раскладывая по разным ящикам.
+
+Я слышал про `notmuch`, однако теги и их фильтрация мне показались
+излишним усложнением, хотелось просто разложить яйца по
+корзинам. Слышал я и про `procmail`, однако он работает как фильтр с
+входящими сообщениями и не очень ложится ни на мой архив сообщений, ни
+на мою привычку получать почту через `isync`. В итоге, в одном из
+чатов, мне посоветовали посмотреть на
+[mblaze](https://git.vuxu.org/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.
+```
+
+## Пишем код: приготовления, флаги и проверки
+
+Теперь, *после* решения основных архитектурных вопросов, можно и код
+писать.
+
+```sh
+set -eu
+```
+
+Shell скрипты вещь хрупкая, и подверженная ошибкам. Чтобы хоть немного
+снизить вероятность их появления (и вероятность загаживания моей
+почтовой директории), давай включим пару опций. Первая, `e`, говорит,
+что если хоть одна команда в скрипте завершится с ошибкой, то мы с
+ошибкой завершим весь скрипт, продолжать его выполнение будет
+бессмысленно и опасно в любом случае. Это не касается команд, для
+которых мы явно проверяем код выполнения. Вторая, `u`, приводит к
+тому, что использование не инициализированной переменной приводит к
+ошибке, а учитывая `e` - к остановке выполнения всего скрипта. У нас
+будет пара моментов, где мы используем _возможно_ не
+инициализированные переменные, и там нам будет нужно явно указать, что
+мы о такой возможности знаем.
+
+По ходу написания скрипта я понял, что выводить помочь мне нужно в
+двух местах, при указании флага и при запуске без аргументов. Чтобы
+избежать дублирования я вынес вывод помощи и выход из скрипта в
+отдельную функцию.
+
+```sh
+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`.
+
+```sh
+if [ -z "${1:-}" ]; then
+ help
+fi
+```
+
+Далее мы обрабатываем флаги, полученные на вход и, на их основе,
+выставляем значения наших внутренних переменных.
+
+```sh
+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 лежит файл с правилами переноса
+почты.
+
+```sh
+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`. Для начала, обработаем самый простой случай - когда пользователь
+хочет только распечатку правил фильтрации.
+
+```sh
+if [ -n "${PRINT_ONLY:-}" ]; then
+ printf "%s\t-> %s\n" "$1" "$2"
+else
+```
+
+Здесь первый аргумент - почтовый адрес, а второй - целевая директория,
+все точно так же, как в файле `.filter`. Я использую символ табуляции,
+так как большинство терминалов столкнувшись с ним поймет, что имеет
+дело с таблицей, и попробует хоть немного выровнять её строки.
+
+Далее мы, в простом конвейере, вызываем три утилиты из `mblaze` и
+первая из них - `mlist`.
+
+```sh
+mlist "$MLIST_FILTER" "$MAIL_DIR/INBOX"
+```
+
+Данная утилита выводит список сообщений из указанного почтового
+ящика. Первый аргумент здесь этот тот самый флаг фильтрации, который
+мы меняем входным параметром `-c`. В `man` по утилите можно посмотреть
+список всех вариантов выбора сообщений, они достаточно просты и
+ориентированы на флаги и местоположение почты, без анализа её
+содержимого.
+
+Теперь, когда мы получили список новых (или старых, если указан `-c`)
+почтовых сообщений, мы передаем его утилите `mpick`. Она позволяет нам
+осуществить более глубокую фильтрацию, на основе содержимого
+сообщения. Лично меня интересовали только отправитель или получатель,
+но в `man mpick` можно увидеть много других возможных правил.
+
+```sh
+mpick -t "\"To\" =~~ \"$1\" ||
+ \"Cc\" =~~ \"$1\" ||
+ \"From\" =~~ \"$1\""
+```
+
+Осталось просто переложить сообщения из одной директории в
+другую. Однако, даже для такого простого действия, я воспользуюсь
+отдельной утилитой, а именно `mrefile`. Дело в том, что у сообщений в
+maildir есть идентификатор, уникальный в пределах ящика, и если
+перекладывать почту без учета этого момента (как я, поначалу, и
+делал), можно напороться на `Maildir error: duplicate UID` при попытке
+запустить `isync`.
+
+```sh
+mrefile -v "$MAIL_DIR/$2"
+```
+
+Ключ `-v` мы указываем чтобы утилита напечатала новый путь для каждого
+сообщения. Эти пути мы передадим финальной программе в нашем
+конвейере - `mv`.
+
+`mrefile` кладет все почтовые сообщения в директорию `cur`, даже если
+изначально они лежали в `new`. Это логично, ведь `new` указывает на
+то, что почтовое сообщение только пришло и не было никем обработано, а
+как раз его обработкой мы с помощью `refile` и занимались. Но для меня
+это было не самым удобным поведением. Я использую `mutt` для работы с
+почтой и в нем новые сообщения, из папки `new`, подсвечиваются как в
+списке самих сообщений, так и в боковой панели, где есть список всех
+моих почтовых ящиков. Сообщения, которые ещё не открывались, но уже
+лежат в `cur`, в терминологии `mutt`, считаются не новыми, а
+"старыми". Они помечаются флагом `O`. Я смог настроить подсветку
+старых сообщений внутри ящика, но подсветить ящик со старыми
+сообщениями в боковой панели мне не удалось. Поэтому я просто двину
+все обработанные сообщения обратно в `new`.
+
+```sh
+xargs -r -n1 -I {} mv {} "$MAIL_DIR/$2/$MAIL_DEST"
+
+```
+
+`mv` не читает аргументы из стандартного ввода, поэтому нам
+потребуется использовать `xargs`. `-r` используется чтобы ничего не
+делать, если ничего не поступило на вход, `-n1` говорит брать
+аргументы по одному, а `-I {}` задает паттерн (`{}`), который будет
+заменен на поступающие на стандартный вход аргументы. По сути, мы для
+каждого сообщения, которое `mrefile` переложила в `cur`, выполним
+команду
+
+```sh
+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](https://github.com/koalaman/shellcheck/wiki/Sc2086), но в
+данном случае я нахожу это излишним и запутывающим код.
+
+Вот и весь скрипт. Он небольшой и вызывается мной после каждой
+синхронизации почты. Читать рассылки стало куда удобнее, а
+предназначенная именно мне почта остается лежать в `INBOX`. Возможно,
+в дальнейшем мои сценарии использования почты станут сложнее и тогда
+больше смысла будет в использовании чего-то вроде `notmuch`, но сейчас
+мне достаточно и этого. Если вам интересна тема фильтрации и обработки
+почты из maildir - посмотрите на `mblaze`, там ещё много интересных
+утилит.
+
+Исходный код целиком можно посмотреть в [моем git](https://git.konyahin.xyz/dotfiles/file/scripts/dot-bin/mfilter.html).