Задача была живая и понятная: сотруднику понадобились все вложения из переписки по конкретным проектам — то есть из писем с определённой темой — в общем ящике отдела.

Писем много, лежат по разным папкам, руками через почтовый клиент это работа на день. Значит, делать надо на сервере.

Получилось в итоге две команды. Но путь к ним оказался итеративным, и это тот случай, когда требования росли по ходу, а не были известны заранее — что само по себе полезно показать честно.

Коротко

  • 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 на такое не ругается и не может ругаться — он честно ищет то, что попросили, и честно ничего не находит. С его точки зрения всё отработало идеально.

Это ровно тот случай, про который у меня давно записано правило: молчание инструмента — не ответ. Пустой результат означает «не нашлось по этому запросу», а не «таких писем нет». Это разные утверждения, и проверять их надо по-разному.

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

Итог

Скрипты работают, выгрузка по новому запросу — одна команда. Задача, которая руками занимала день, теперь занимает столько, сколько идёт поиск.