Локальное тестирование сообщений и почты

Назначение

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

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

Если нужен именно настоящий почтовый сервер компании — см. раздел Вариант без докера: свой ящик на mail.ufanet.ru. Начните с чтения предупреждений в нём: локальная ERP умеет достучаться до продовых ящиков, и одно неверное нажатие удаляет письмо из ящика живого сотрудника.

Инструкция рассчитана на то, что вы уже умеете запускать ERP локально и заходить в неё браузером.

Как это устроено

Три вещи, которые надо понять до первой команды, иначе настройка кажется магией.

Тип сообщения — это почтовый ящик. В конфигурации ERP каждый ящик описан блоком messageType.<id>.*: класс-обработчик, адрес, параметры IMAP и SMTP. Для писем класс всегда ru.bgcrm.dao.message.MessageTypeEmail. Один блок — один ящик, и <id> этого блока лежит потом в каждой строке таблицы n_message в поле type_id.

Приём и отправка — разные протоколы, но одна задача планировщика. Класс ru.bgcrm.worker.MessageExchange по расписанию обходит все настроенные типы и у каждого вызывает process(), а тот делает два дела подряд: readBox() забирает новые письма по IMAP и sendMessages() отправляет накопившиеся исходящие по SMTP. Без планировщика письма из ERP никуда не уйдут — они просто лежат в базе.

Исходящее письмо — это строка в n_message с пустым to_dt. Когда вы жмёте «Отправить» в карточке процесса, письмо только записывается в базу: direction=2, to_dt IS NULL. Реально его отправит следующий проход MessageExchange, и он же проставит to_dt. Поэтому «письмо не отправилось» чаще всего означает «обмен для этого типа не настроен», а не ошибку в коде.

Что где смотреть
Что Где

Строки сообщений

таблица n_message

Разбор письма, ошибки IMAP/SMTP

app/log/all.log, логгер MessageTypeEmail

Настройки ящиков

активная глобальная конфигурация, блоки messageType.<id>.*

Расписание обмена

та же конфигурация, блоки scheduler.task.<n>.*

Шаг 1. Почтовый сервер в докере

Берём GreenMail — SMTP и IMAP в одном контейнере, без регистрации и смс.

docker run -d --name greenmail-erp -p 3025:3025 -p 3143:3143 \
  -e GREENMAIL_OPTS="-Dgreenmail.setup.test.smtp -Dgreenmail.setup.test.imap \
                     -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled -Dgreenmail.verbose" \
  greenmail/standalone:2.1.0

Порты: 3025 — SMTP, 3143 — IMAP. Проверка авторизации выключена, поэтому логин и пароль подойдут любые, а ящик заводится сам при первом обращении.

Контейнер поднимает ещё и свой веб-API на 8080 внутри себя, но наружу этот порт мы не публикуем.

Шаг 2. Папки в ящике

ERP не создаёт папки самостоятельно. Если папки для обработанных или пропущенных писем нет, MessageTypeEmail молча выходит из разбора: ни ошибки, ни письма. Это самая частая причина «ничего не работает», поэтому папки создаём сразу.

import imaplib
M = imaplib.IMAP4('127.0.0.1', 3143)
M.login('erp@localhost', 'test')
for f in ('CRM_PROCESSED', 'CRM_SKIPPED', 'CRM_SENT'):
    print(f, M.create(f))
M.logout()

Ожидаемый ответ на каждую папку — ('OK', [b’CREATE completed.']).

Шаг 3. Тип сообщения в конфигурации

Открываем в ERP Администрирование → Конфигурация, редактируем активную конфигурацию («Основной») и дописываем блок в конец.

messageType.900.title=GreenMail (локальный тест)
messageType.900.class=ru.bgcrm.dao.message.MessageTypeEmail
messageType.900.email=erp@localhost
messageType.900.host=localhost
messageType.900.port=3143
messageType.900.login=erp@localhost
messageType.900.pswd=test
messageType.900.folderIn=INBOX
messageType.900.folderProcessed=CRM_PROCESSED
messageType.900.folderSkipped=CRM_SKIPPED
messageType.900.folderSent=CRM_SENT
messageType.900.mail.transport.protocol=smtp
messageType.900.mail.smtp.host=localhost
messageType.900.mail.smtp.port=3025
Не берите маленький <id>. В локальную конфигурацию подключается боевой конфиг ящиков (include.20), где заняты номера с 1 по 53. Если написать messageType.1.*, ваш блок перекроется настоящим crm@ufanet.ru, и вы будете отлаживать чужой ящик. Берите номер из третьей сотни — 900 свободен.

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

Проверяем, что тип виден. В браузере на любой странице ERP откройте консоль и выполните:

await (await fetch('/user/message.do?action=messageTypes&types=EMAIL&responseType=json',
                   {method:'POST'})).json();

В ответе, в data.list, должен быть ваш тип с id: 900.

Шаг 4. Отправка письма из ERP

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

scheduler.task.900.class=ru.bgcrm.worker.MessageExchange
scheduler.task.900.messageTypeIds=900

Параметр messageTypeIds ограничивает обмен вашим ящиком. Без него задача полезет во все типы из боевого конфига, то есть в реальные ящики Уфанета — так делать нельзя. Полей с расписанием (hours, minutes) не ставим: пустое расписание означает «каждую минуту», а планировщик просыпается раз в 60 секунд.

Почему все конфигурации с планировщиком «неактивны»

Открыв список конфигураций, легко решить, что планировщик вообще не работает: у конфигураций «Планировщик 3.0» и «Планировщик 3.0 [Активный]» галка Активный снята, и на проде тоже. Это не поломка, просто слово выбрано неудачно.

«Активный» здесь означает «корневой», а не «включённый». При старте приложение берёт одну конфигурацию с active=1 (ConfigDAO.getActiveGlobalConfig(), WHERE active=true), а все остальные подмешивает к ней строками include.<id>=1. Поэтому у всех неглавных конфигураций галка снята — иначе система не поняла бы, с какой начинать. Слово «[Активный]» в названии конфигурации 30 дописано руками, к флагу отношения не имеет.

Строки include.<id>=1 берутся из двух мест сразу:

  • из самой корневой конфигурации в базе;

  • из файла bgcrm_<логин>.properties в корне проекта (на сервере — bgcrm.properties рядом с инстансом). Настройки файла и базы сливаются в одну общую мапу.

Так это устроено в продакшене: конфигурации с задачами планировщика нет в списке include корневой конфигурации — она подключается из файла инстанса на erp0 двумя строками:

scheduler.start=1
include.30=1

На erp2 в том же файле стоит scheduler.start=0. Поэтому почту разбирает только erp0, хотя конфигурация с задачами одна на всех.

Локально в bgcrm_<логин>.properties обе строки закомментированы. При этом сам планировщик у вас работает: у параметра scheduler.start значение по умолчанию — true, поток стартует и раз в минуту просыпается. Просто задач у него нет, потому что ни одна конфигурация с scheduler.task.* не подключена.

Отсюда и рецепт этой инструкции: кладите задачу в активную конфигурацию, тогда вопрос с include не возникает вовсе.

Не подключайте локально include.30. Это 54 продовые задачи разом, включая обмен по 51 реальному ящику Уфанета и рассылки клиентам.

Что задача доехала, видно по трём строкам в app/log/all.log:

[Scheduler]       Class: ru.bgcrm.worker.MessageExchange; dm: []; dw: []; hours: []; minutes: []
[Scheduler]       Running scheduled task: ru.bgcrm.worker.MessageExchange@5f2040b2
[MessageExchange] Message types: [900]

Первая печатается при каждом перечитывании конфигурации и означает «задача найдена и разобрана»; пустые hours/minutes — то самое «каждую минуту». Вторая — что задача запущена. Третья — что она ограничена вашим ящиком. Нет первой строки — задача лежит не в той конфигурации.

Теперь письмо. Заведите процесс типа, у которого включена вкладка сообщений (processShowMessages=1 в конфигурации типа), откройте вкладку Сообщения, нажмите Создать, выберите тип «GreenMail (локальный тест)», заполните получателя и текст, сохраните.

Через минуту письмо должно оказаться в GreenMail:

Письмо придёт не совсем таким, каким вы его написали, — ERP дописывает три вещи:

  • маркер процесса в теме: Исходящее из ERP превращается в Исходящее из ERP [erp@localhost#122], где 122 — код процесса. По нему потом опознаётся ответ клиента, поэтому тему в ответе менять нельзя;

  • подпись в конце текста («Сообщение подготовлено системой Avantys ERP…»), её можно переопределить параметрами signExpression и signFooter типа сообщения;

  • вложение History.txt — вся переписка по процессу одним файлом.

Копия отправленного письма при этом складывается в папку из folderSent.

Как понять, что обмен вообще запустился: в app/log/all.log за одну минуту появляются три строки — Reload tasks config, Running scheduled task: ru.bgcrm.worker.MessageExchange и Starting EMail daemon, box: erp@localhost. Строка про ящик должна быть одна. Если их десятки и там чужие адреса — вы забыли messageTypeIds, и стенд полез в продовые ящики.

Что смотреть, если письма нет:

  • в n_message у строки остался to_dt IS NULL — обмен не отработал: проверьте, что задача планировщика добавлена в активную конфигурацию и что в логе есть строка Starting EMail daemon, box: erp@localhost;

  • в логе есть ошибка SMTP — проверьте порт 3025 и что контейнер жив (docker ps);

  • to_dt проставился, а письма в GreenMail нет — смотрите адрес получателя, письмо ушло в другой ящик.

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

Шаг 5. Приём письма от клиента

Отправляем «ответ клиента» — обычным письмом на адрес ящика, с маркером процесса в теме.

Дальше есть два пути.

По расписанию. Задача обмена из шага 4 через минуту сама заберёт письмо, привяжет его к процессу и переложит в CRM_PROCESSED.

Вручную, не дожидаясь. Удобно, когда отлаживаешь и не хочешь ждать. Действие newMessageLoad выполняет тот же разбор, что и планировщик, но прямо сейчас. В messageId подставьте Message-ID из вывода скрипта выше, вместе с угловыми скобками:

await fetch('/user/message.do?action=newMessageLoad&typeId=900&messageId='
            + encodeURIComponent('<178716204369.24189.12026963957311248591@example.org>')
            + '&responseType=json', {method:'POST'});

Проверяем результат в бд:

SELECT id, type_id, direction, process_id, subject, from_dt, processed
FROM n_message ORDER BY id DESC LIMIT 5;

У входящего письма должно быть direction=1, processed=1 и process_id вашего процесса. В карточке процесса письмо появится на вкладке Сообщения.

Шаг 6. Письмо без маркера

Отправьте в ящик письмо с обычной темой, без [erp@localhost#…] — так выглядит клиент, который написал новое письмо вместо ответа.

Такое письмо в процесс не попадёт: оно останется во входящих и появится в оснастке Сообщения в главном меню, в очереди неразобранного. Откройте его там, найдите контрагента режимом поиска и привяжите к процессу руками — это и есть работа оператора первой линии.

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

Вариант без докера: свой ящик на mail.ufanet.ru

Докер нужен не всегда. Почтовый сервер компании доступен с рабочего пк, и стенд можно собрать на нём — тогда проверяется только та инфраструктура, что работает на проде.

Что доступно с пк
Хост Порт Что это

mail.ufanet.ru

143

IMAP, приём

mail.ufanet.ru

25, 587

SMTP, отправка

mail.core.ufanet.ru

25

внутренний релей, с пк недоступен

В боевой конфигурации в качестве SMTP указан mail.core.ufanet.ru — он виден только с серверов. Локально в блоке своего типа пишите mail.smtp.host=mail.ufanet.ru, иначе исходящие будут копиться в базе, а в логе будет таймаут.

Так делать нельзя

В локальную конфигурацию подключается боевой конфиг ящиков (include.20): 52 настоящих ящика Уфанета с рабочими паролями, и mail.ufanet.ru с пк открывается. То есть локальная ERP прямо сейчас может работать с живой корпоративной почтой. Отсюда правило: готовые типы 1–53 не трогаем.

Опасен здесь не просмотр, а привязка письма к процессу. Когда вы открываете письмо из оснастки и привязываете его, MessageTypeEmail.processMessage копирует письмо в папку обработанных, ставит на оригинал флаг DELETED и делает expunge — письмо исчезает из ящика живого сотрудника, и вернуть его нельзя. Сам список писем в оснастке безопасен: папка открывается только на чтение.

Так можно: свой ящик и отдельная папка

Идея простая — дать ERP не весь ящик, а одну служебную папку, куда вы сами кладёте тестовые письма. Тогда рабочая переписка недосягаема для стенда даже при ошибке в конфигурации.

  1. В своём почтовом клиенте создайте три папки: ERP_TEST, ERP_TEST_PROCESSED, ERP_TEST_SKIPPED.

  2. Добавьте в активную конфигурацию блок со своим адресом и свободным номером:

    messageType.900.title=Мой ящик (локальный тест)
    messageType.900.class=ru.bgcrm.dao.message.MessageTypeEmail
    messageType.900.email=ivanov_i@ufanet.ru
    messageType.900.host=mail.ufanet.ru
    messageType.900.login=ivanov_i
    messageType.900.pswd=<пароль от своей почты>
    messageType.900.folderIn=ERP_TEST
    messageType.900.folderProcessed=ERP_TEST_PROCESSED
    messageType.900.folderSkipped=ERP_TEST_SKIPPED
    messageType.900.folderSent=ERP_TEST_PROCESSED
    messageType.900.mail.transport.protocol=smtp
    messageType.900.mail.smtp.host=mail.ufanet.ru
    messageType.900.mail.smtp.user=ivanov_i
    messageType.900.mail.smtp.pswd=<пароль от своей почты>
  3. Задачу обмена ставьте так же, как в шаге 4, обязательно с messageTypeIds=900.

Что выбрать

GreenMail Свой ящик

Настройка

одна команда докера

папки в почтовом клиенте + пароль в конфиге

Пароли

не нужны

свой рабочий, открытым текстом

Отправка

всегда работает

работает, письмо реально уходит

Риск задеть чужую почту

нет

есть, если ошибиться в folderIn

Когда брать

обычная разработка и отладка

проверка настоящих IMAP/SMTP, кодировок, вложений

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

Как письмо находит свой процесс

Всё держится на одной строке в теме письма и одном регулярном выражении.

Метка ставится при отправке

Отправляя письмо из карточки процесса, ERP смотрит на тему и, если метки там ещё нет, дописывает её в конец:

Исходящее из ERP  →  Исходящее из ERP [erp@localhost#122]
                                       ^^^^^^^^^^^^^ ^^^
                                       адрес ящика   код процесса

Дальше вся надежда на то, что клиент нажмёт «Ответить», а его почтовый клиент сохранит тему, добавив к ней Re:.

Метка читается при приёме

У каждого типа сообщения при создании компилируется своя регулярка — из его собственного адреса:

pattern = Pattern.compile(mailConfig.getEmail().replaceAll("\\.", "\\\\.") + "#(\\d+)");

Для ящика erp@localhost это erp@localhost#(\d+). Обмен перебирает письма в папке и на каждом вызывает getProcessId(subject) — обычный find() по теме, из группы захвата берётся число. Квадратные скобки в выражение не входят, они нужны только для читаемости.

Нашлось число — работает processMessage:

  1. достаёт процесс по этому коду из базы (нет такого — ошибка в лог, письмо в папку пропущенных);

  2. пишет строку в n_message, где process_id = найденное число, direction=1, processed=1, автор — системный пользователь;

  3. перекладывает письмо в папку обработанных;

  4. поднимает событие ProcessMessageAddedEvent.

Типа процесса в маршрутизации нет вообще

Это главное, что стоит уложить в голове. Письмо адресуется конкретному процессу по его коду, а не типу. Связки «ящик ul@ufanet.ru → тип процесса 9384» в системе не существует. Тип узнаётся уже потом, из самого процесса, и нужен только для одного — понять, какой скрипт-обработчик дёрнуть при событии.

Так что на вопрос «как письмо попадает к нужному типу процесса» ответ такой: оно к нему и не попадает. Оно попадает к процессу, а тип у процесса уже есть.

Если метки нет

Система не знает ничего и в процесс письмо не кладёт. Оно остаётся во входящих и всплывает в оснастке Сообщения в главном меню — очереди неразобранного. Там его открывает сотрудник, ищет контрагента или договор режимами поиска (messageType.<id>.search.N.class — по email, по телефону, по номеру договора) и привязывает руками: либо к существующему процессу по коду, либо к новому, который тут же создаёт.

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

Есть и третий путь: если у ящика стоит processMessageWithEvent=true, на письмо без метки поднимается событие MessageReceivedEvent, и разбирает его прикладной код. На проде так настроен один ящик, bgcrm@ufanet.ru.

Где это ломается

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

Ответ ушёл в другой ящик

Каждый тип узнаёт только собственный адрес. Письмо отправили с ul@ufanet.ru, клиент ответил на zapros@ufanet.ru — регулярка второго ящика метку первого не увидит.

Клиент написал новым письмом, а не ответом

Метки нет — процесса нет, письмо уходит в неразобранное.

Тему переписали

Некоторые почтовые клиенты и корпоративные шлюзы режут или переоформляют тему, метка исчезает вместе с ней. Поэтому в стандартной подписи ERP есть просьба тему не менять.

Процесс удалили

Код в теме есть, процесса по нему нет — письмо уезжает в папку пропущенных с ошибкой в логе.

Заголовки In-Reply-To и References, по которым почтовые системы обычно связывают переписку, не используются вообще.

Частые грабли

Ящик настроен, но писем нет

Не созданы папки из шага 2. MessageTypeEmail выходит без ошибки, если нет folderProcessed или folderSkipped.

Тип сообщения в списке есть, но с чужим названием

Занятый <id>: боевой конфиг ящиков перекрыл ваш блок. Возьмите номер из третьей сотни.

Письмо лежит в базе и не уходит

Нет задачи MessageExchange или она ограничена другими messageTypeIds. Проверьте по логу: Starting EMail daemon, box: ….

Добавил scheduler.task, но ничего не запускается

Задача попала в неактивную конфигурацию. Кладите её в ту, у которой стоит галка Активный, — остальные подключаются только через include.<id>=1. Подробно — в разделе Почему все конфигурации с планировщиком «неактивны».

Все конфигурации с планировщиком неактивны

Так и задумано: «Активный» означает «корневой», а не «включённый». Конфигурация с задачами подключается к корневой строкой include.<id>=1 из файла bgcrm.properties инстанса. Там же живёт scheduler.start, которым планировщик включён на erp0 и выключен на erp2.

Ответ клиента не привязался к процессу

В теме нет маркера или он от другого ящика. Тип узнаёт только собственный адрес.

Правки в коде не подхватились

Изменения в Kotlin/Java требуют перезапуска приложения, изменения конфигурации — нет.

Уборка

docker rm -f greenmail-erp

И уберите из конфигурации блоки messageType.900. и scheduler.task.900. — иначе при каждом запуске ERP будет минуту за минутой стучаться в несуществующий почтовый сервер и писать ошибки в лог.

Никогда не прописывайте в локальную конфигурацию реальный ящик Уфанета. Локальный инстанс с продовым ящиком начнёт вычитывать из него письма и перекладывать их в служебные папки — то есть уводить рабочую почту у живых сотрудников.