blog

Source for my blog
git clone git://git.konyahin.xyz/blog
Log | Files | Refs

commit 3884cebf5460bcd05c1fc89688a7be851508223c
parent 54c093fc6f7a6d831d77c5000288fc2a4b840457
Author: Anton Konyahin <me@konyahin.xyz>
Date:   Fri,  5 May 2023 11:15:52 +0300

post: mail filtering

Diffstat:
Acontent/blog/mail-filtering.md | 331+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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).