Задача была живая и понятная: сотруднику понадобились все вложения из переписки по конкретным проектам — то есть из писем с определённой темой — в общем ящике отдела.
Писем много, лежат по разным папкам, руками через почтовый клиент это работа на день. Значит, делать надо на сервере.
Получилось в итоге две команды. Но путь к ним оказался итеративным, и это тот случай, когда требования росли по ходу, а не были известны заранее — что само по себе полезно показать честно.
Коротко
doveadm searchпо всем папкам сразу:mailbox '*'.- Несколько условий
SUBJECTработают как «И», а не как «ИЛИ».- Дедупликация по MD5 обязательна: одно вложение ходит по пересылкам десятки раз.
- Пустой результат оказался опечаткой в слове.
doveadmоб этом не сообщает.
Поиск
Начинается всё с doveadm search внутри контейнера Dovecot. Ключевой параметр — mailbox '*': искать по всем папкам сразу, а не по одной. Иначе придётся знать заранее, где лежат нужные письма, а весь смысл задачи в том, что не знаешь.
Важная деталь, на которую стоит обратить внимание: несколько условий SUBJECT подряд работают как «И», а не как «ИЛИ».
Это ровно противоположно тому, чего ждёшь от поиска. Привычка из поисковых систем говорит: чем больше слов, тем шире охват. Здесь наоборот — каждое следующее слово сужает выборку, и три ключевых слова могут дать ноль результатов там, где одно давало сотни.
Как рос скрипт
Первая версия сохраняла найденные письма целиком, в формате .eml. Работала, но выдавала не то: человеку нужны были вложения, а не письма.
Доработка: сохранять только те письма, где вложения действительно есть. Отсекло заметную часть.
Второй скрипт делал уже правильную вещь — сохранял только вложения, без .eml, через питоновский хелпер, который читает письмо из stdin.
Дальше требования добавлялись по мере того, как человек смотрел на результат:
- все вложения в одну папку и без дублей. Дедупликация по MD5 — потому что одно и то же вложение ходит по пересылкам десятки раз, и без неё выгрузка состоит в основном из копий;
- пропускать картинки из подписей. Они узнаются по именам вида
image0*и составляют изрядную долю «вложений», ни одно из которых никому не нужно; - затем наоборот — раскладывать по подпапкам по теме письма, отрезая
RE:,FW:иОтвет:и вычищая символы, недопустимые в имени файла.
Последнее требование отменило предыдущее — «всё в одну папку» превратилось в «разложить по темам». Это нормальный ход работы: человек увидел результат и понял, чего на самом деле хотел. Плохо было бы спорить, а не переделывать.
Оба скрипта в итоге устроены одинаково: первый аргумент — каталог выгрузки, дальше ключевые слова темы. Новая выгрузка по новому запросу — одна команда.
Что получилось
Обёртка, которая ищет письма и скармливает каждое питоновскому хелперу:
#!/bin/bash
USER_BOX="otdel@example.com"
MAILCOW_DIR="/opt/mailcow_data/mailcow-dockerized" # у нас путь не стандартный
OUT="$1"; shift
[ -z "$OUT" ] || [ $# -eq 0 ] && { echo "usage: $0 <out_dir> \"часть темы\" ..."; exit 1; }
cd "$MAILCOW_DIR" || exit 1
mkdir -p "$OUT"
# каждое слово -> отдельное условие SUBJECT (логика И)
Q=(); for s in "$@"; do Q+=(SUBJECT "$s"); done
DC="docker compose exec -T dovecot-mailcow doveadm"
CNT=$($DC search -u "$USER_BOX" mailbox '*' "${Q[@]}" < /dev/null | wc -l)
echo "найдено писем: $CNT"
[ "$CNT" -eq 0 ] && exit 1
$DC search -u "$USER_BOX" mailbox '*' "${Q[@]}" < /dev/null |
while read -r mguid uid; do
$DC -f pager fetch -u "$USER_BOX" text mailbox-guid "$mguid" uid "$uid" < /dev/null \
| sed '1{/^text:$/d}' | sed '/^\f$/d' \
| python3 /root/att-from-stdin.py "$OUT"
done
Строчка echo "найдено писем: $CNT" появилась там не сразу, и появилась она ровно из-за истории, которая будет ниже. Без неё скрипт при нуле писем молча завершался, и было непонятно, что произошло.
Сам хелпер — он читает письмо из stdin, вытаскивает вложения и раскладывает их по теме:
import sys, os, re, email, hashlib
from email import policy
root = sys.argv[1]
msg = email.message_from_binary_file(sys.stdin.buffer, policy=policy.default)
# тема -> имя папки: срезаем RE:/FW:/Ответ: и символы, запрещённые в ФС
subj = str(msg["subject"] or "без темы")
subj = re.sub(r'^\s*((re|fw|fwd|ответ|пересылка)\s*:\s*)+', '', subj, flags=re.I)
subj = re.sub(r'[\\/:*?"<>|\r\n\t]+', ' ', subj).strip(" .")[:100] or "без темы"
dst = os.path.join(root, subj)
# хэши всего, что уже выгружено — одно вложение из пересылок сохраняется один раз
seen = set()
for d, _, files in os.walk(root):
for f in files:
with open(os.path.join(d, f), "rb") as fh:
seen.add(hashlib.md5(fh.read()).hexdigest())
n = 0
for part in msg.iter_attachments():
data = part.get_payload(decode=True)
if not data:
continue
fn = os.path.basename((part.get_filename() or f"attachment_{n}").replace("\\", "/"))
if fn.lower().startswith("image0"): # картинки из подписей
continue
h = hashlib.md5(data).hexdigest()
if h in seen:
continue
seen.add(h)
os.makedirs(dst, exist_ok=True) # папку создаём только если есть что сохранить
base, ext = os.path.splitext(fn)
path, i = os.path.join(dst, fn), 1
while os.path.exists(path): # то же имя, другое содержимое — новая версия
path = os.path.join(dst, f"{base}_{i}{ext}"); i += 1
with open(path, "wb") as out:
out.write(data)
n += 1
print(f"{n} новых вложений | {subj}")
Три детали в нём стоят объяснения.
os.makedirs вызывается внутри цикла, а не до него. Из-за этого папка с темой создаётся только тогда, когда в неё реально есть что положить. Иначе выгрузка обрастает десятками пустых каталогов от писем без вложений, и по ней невозможно понять, где что-то нашлось.
Обрезка темы до ста символов — та же история, что и с пустыми папками в Thunderbird, только с другой стороны. Тема письма может быть любой длины, а имя каталога — нет.
Суффиксы _1, _2 — это не дубли. Дубли отсекаются раньше, по хэшу содержимого. Суффикс появляется в обратном случае: имя то же, а содержимое другое — например, смета, присланная повторно в исправленном виде. Такие файлы сохраняются специально, и это как раз то, что человеку нужнее всего: видеть, что версий было несколько.
Как выглядит запуск
/root/export-att.sh /srv/export/obj-a "объект а" "площадка"
/srv/export/obj-a/Объект А Площадка 2, 2 этап/Смета.xlsx
/srv/export/obj-a/Объект А Площадка 2, 2 этап/Чертежи.pdf
Проверка, что дедупликация отработала и одинаковых по содержимому файлов не осталось:
find /srv/export/obj-a -type f -exec md5sum {} + | sort | uniq -w32 -d
# пусто — дублей нет
Вот это и есть проверка с известным ответом: я не «считаю, что дублей нет», а показал команду, которая нашла бы их, если бы они были.
Грабля про доверие к инструменту
Один запуск вернул пустой результат. Без ошибки, без предупреждения — просто ничего не нашлось. Я успел засомневаться в скрипте, потом в поиске, потом в Dovecot.
/root/export-att.sh /srv/export/obj-a "площядка"
# найдено писем: 0 — никакой ошибки, просто пусто
Причина оказалась в опечатке в слове запроса: «площядка» вместо «площадка».
doveadm на такое не ругается и не может ругаться — он честно ищет то, что попросили, и честно ничего не находит. С его точки зрения всё отработало идеально.
Это ровно тот случай, про который у меня давно записано правило: молчание инструмента — не ответ. Пустой результат означает «не нашлось по этому запросу», а не «таких писем нет». Это разные утверждения, и проверять их надо по-разному.
Практический вывод для таких выгрузок: прежде чем доверять нулю, проверьте запрос на заведомо существующем письме. Одна проверка с известным ответом стоит минуты и снимает целый класс сомнений.
Итог
Скрипты работают, выгрузка по новому запросу — одна команда. Задача, которая руками занимала день, теперь занимает столько, сколько идёт поиск.