Meridian Office

Meridian Mail — почта, календарь, контакты, задачи

Документ приложения по соглашениям раздела 8 библии (docs/00-foundation.md). Версия 0.1 · 2026-10-03. Фаза 5 · редакции Home и выше (Feature::Mail; календарь, контакты и задачи входят в Feature::Mail, серверные коннекторы Exchange — Feature::MailExchange, Business+) · архив .mmx · цвет --app-mail #1C7ED6 · значок «конверт» · идентификаторы экранов ML-xx · макет mockups/mail.html.

Содержание: 1. Назначение и аналоги · 2. Функции · 3. Экраны · 4. Модель данных · 5. Алгоритмы и скрипты · 6. Форматы · 7. Горячие клавиши · 8. Критерии готовности · 9. Риски · 10. Вопросы к библии.


1. Назначение и аналоги

Meridian Mail — персональный информационный менеджер (PIM): почта (IMAP/SMTP/POP3/Exchange/Graph, OAuth2 у крупных провайдеров), календарь (iCalendar, CalDAV, Google/Microsoft API), контакты (vCard, CardDAV), задачи (VTODO/CalDAV, Google Tasks, Microsoft To Do). Работает автономно: вся почта, события и контакты хранятся в локальной БД SQLite с телами писем в сжатых файлах; отправка — через очередь с повторами. Составление писем — HTML-редактор на движке Meridian Write; поиск — локальный индекс tantivy плюс серверный IMAP SEARCH.

Аналог Сильные стороны Слабости, которые закрываем
Microsoft Outlook Exchange, календарь, правила, объектная модель тяжёлый, PST-привязка, Windows/macOS, платный, слабый IMAP
Mozilla Thunderbird открытость, IMAP, OpenPGP, расширения устаревший интерфейс, слабый календарь/контакты, нет единого редактора
Apple Mail + Calendar + Contacts интеграция с macOS только Apple, три приложения, нет правил на сервере, скудные фильтры
eM Client / Mailbird единый интерфейс закрытые, Windows/macOS, платные
Gmail/Outlook.com (веб) поиск, доступность только онлайн, нет локального контроля данных

Отличия Meridian Mail: один интерфейс для четырёх модулей на трёх ОС; локальный индекс и байесовский спам-фильтр; безопасный рендер HTML собственным движком (без браузера по умолчанию); письма в открытых форматах (EML/ICS/VCF) и архив .mmx; импорт PST/OST собственным парсером; MeridianScript вместо VBA с объектной моделью, близкой к Outlook Object Model.

Связи в пакете: Write (редактор писем, печать), Sheets (экспорт/импорт таблиц, рассылки слиянием — P1), Notes («Отправить в Notes», ссылки на письма), Forms (рассылка форм, ответы по почте), Base (контакты как источник), Hub (уведомления, единый поиск, счётчик непрочитанных, аккаунт Meridian ID), Meridian Cloud (большие вложения, фаза 6).

Код: kernel/crates/meridian-mail/ (протоколы, БД, синхронизация, индекс, спам), kernel/crates/meridian-cal/ (iCalendar, RRULE, tz, CalDAV), kernel/crates/meridian-contacts/ (vCard, CardDAV, дедупликация), codecs/mail/ (MIME, PST, mbox, msg, TNEF), shell/src/mail/ (QML/C++).


2. Функции

2.1 Учётные записи

Функция Описание Приоритет
IMAP + SMTP RFC 3501/9051 (IMAP4rev1/rev2), расширения: IDLE, CONDSTORE, QRESYNC, MOVE, UIDPLUS, ESEARCH, LIST-EXTENDED, SPECIAL-USE, NAMESPACE, COMPRESS=DEFLATE, LITERAL+, BINARY, SORT/THREAD (при наличии); SMTP RFC 5321 + submission 587 (STARTTLS) / 465 (implicit TLS), SMTPUTF8, 8BITMIME, SIZE, PIPELINING, DSN P0
Автоопределение настроек по адресу: 1) встроенная база провайдеров (Gmail, Outlook.com/Hotmail/Live, Яндекс, Mail.ru, iCloud, Yahoo, GMX, Fastmail, Proton Bridge, Rambler, Zoho); 2) Thunderbird autoconfig (https://autoconfig.<домен>/mail/config-v1.1.xml, https://<домен>/.well-known/autoconfig/mail/config-v1.1.xml, ISPDB autoconfig.thunderbird.net); 3) DNS SRV RFC 6186 (_imaps._tcp, _imap._tcp, _submissions._tcp, _submission._tcp); 4) Exchange Autodiscover v2; 5) эвристика imap./mail./smtp.<домен> по MX; 6) ручной ввод P0 (Autodiscover — P1)
POP3 RFC 1939 + UIDL, TOP, STLS; «оставлять на сервере N дней» P0
OAuth2 Gmail, Microsoft (Outlook.com, Microsoft 365), Яндекс, Mail.ru — Authorization Code + PKCE, loopback-редирект (http://127.0.0.1:<port>/), системный браузер; SASL XOAUTH2 / OAUTHBEARER; обновление токенов в фоне; токены в хранилище ОС P0
Exchange EWS Exchange 2013+/on-prem и Microsoft 365: папки, письма, календарь, контакты, задачи, free/busy, Autodiscover, NTLM/Basic/OAuth2 P1
Microsoft Graph Microsoft 365: mail/calendar/contacts/todo через REST + delta queries, push через webhooks (только при Cloud) → polling P1
Google API Gmail API (labels, history.list для дельт) как альтернатива IMAP; Google Calendar API, People API, Tasks API P1 (календарь/контакты — P0 через CalDAV/CardDAV)
JMAP RFC 8620/8621 (Fastmail, Stalwart) P2
Несколько учётных записей и профилей неограниченно аккаунтов; профили (отдельные БД: «Работа», «Дом») с переключением и запуском --profile P0
Идентификации (identities) несколько адресов/подписей на аккаунт, псевдонимы, «ответить с того адреса, на который пришло» P0
Безопасность соединения TLS 1.2+/rustls, проверка сертификатов, исключения с подтверждением и отпечатком, SNI, OCSP stapling; STARTTLS обязателен, если нет implicit TLS P0
Прокси системные настройки, SOCKS5/HTTP P1

2.2 Почта

Функция Описание Приоритет
Трёхпанельный вид папки · список (строки или карточки) · чтение; вертикальный/горизонтальный; плотность 3 уровня; группировка по дате/отправителю/теме; закреплённые письма P0
Цепочки (threading) алгоритм JWZ по Message-ID/In-Reply-To/References + тема; «разговор» со всеми папками (Gmail-стиль) или в пределах папки; свёрнутые цитаты P0
Единый ящик «Все входящие» по всем аккаунтам, цветовые метки аккаунтов P0
Папки и ярлыки IMAP-папки (иерархия, подписки, SPECIAL-USE), Gmail-ярлыки как папки + мультиярлыки, локальные папки, избранные папки, счётчики P0
Правила и фильтры условия (от/кому/тема/тело/заголовок/размер/вложение/аккаунт/спам-оценка; содержит/равно/regex/в адресной книге), действия (переместить/копировать/пометить/флаг/категория/удалить/переслать/ответить шаблоном/запустить скрипт/уведомить/остановить обработку); выполнение при получении, вручную, по расписанию; порядок и журнал P0
Серверные правила Sieve (RFC 5228, ManageSieve) — редактор правил с переводом в Sieve и обратно; Exchange Inbox Rules через EWS/Graph P2 (Exchange — P1)
Поиск мгновенный локальный (tantivy): текст, от/кому, тема, вложения (имена и содержимое через кодеки), даты, флаги, папки, аккаунты; операторы from:, to:, subject:, has:attachment, before:, after:, in:, is:unread, larger:; серверный IMAP SEARCH/ESEARCH для неполного кэша; сохранённые поиски (виртуальные папки) P0
Автономная работа полный или ограниченный кэш (заголовки + тела за N дней/папки по выбору), очередь действий (флаги, перемещения, отправка) с воспроизведением при подключении; индикатор состояния P0
Составление HTML-редактор (движок Write: стили, списки, таблицы, изображения, ссылки, форматирование, вставка из Office с сохранением), простой текст с автопереносом/format=flowed, подписи по идентификации, шаблоны писем (с полями), автодополнение адресов (контакты + история), проверка орфографии, вложения перетаскиванием, отложенная отправка (дата/время, «утром», «в понедельник»), «отменить отправку» 5–30 с, важность, запрос уведомления о прочтении/доставке (DSN), ответ/ответить всем/переслать (как вложение .eml или в тексте), цитирование (сверху/снизу), черновики с автосохранением (локально + IMAP Drafts) P0
Отзыв письма Exchange recall через EWS/Graph P2
Вложения из облака вставка ссылки на файл из Meridian Cloud/OneDrive/Google Drive/Яндекс Диск (через Hub); большие вложения (> лимита сервера) — автоматическая загрузка в Meridian Cloud со ссылкой и сроком P1 (фаза 6 для Meridian Cloud)
Просмотр вложений предпросмотр документов Office/PDF/изображений через кодеки пакета без открытия приложения; «Открыть в Write/Sheets/…», «Сохранить все», «Отправить в Notes» P0
S/MIME RFC 8551: подпись и шифрование, сертификаты из хранилища ОС и .p12, проверка цепочек, индикаторы, автосбор сертификатов из подписанных писем P0
OpenPGP RFC 4880/9580 через sequoia-openpgp: ключи, подпись/шифрование PGP/MIME (RFC 3156), Autocrypt (заголовки, автоматический обмен ключами, настройка «предпочитать шифрование»), WKD-поиск ключей, keyserver (hkps) P0 (Autocrypt — P1)
Антифишинг подсказки: ссылка ведёт не туда, куда текст; отправитель с похожим доменом (IDN-гомоглифы, punycode); SPF/DKIM/DMARC-результаты из Authentication-Results; письмо «от себя» снаружи; вложения с опасными расширениями; баннер «внешний отправитель» (для организаций) P0
Внешние изображения блокируются по умолчанию; разрешить для письма/отправителя/домена; трекинг-пиксели (1×1, известные домены) помечаются P0
Спам-фильтр локальный байесовский (обучается на «Спам/Не спам»), объединённый с серверными метками ($Junk, X-Spam-Status, Gmail SPAM); белые списки (контакты); карантин P0
Уведомления системные (Windows Toast/macOS Notification Center/libnotify) с действиями «Архив», «Удалить», «Ответить»; правила тишины; значок в трее/доке с числом; Hub-центр уведомлений P0
Категории и флаги цветные категории (как Outlook) синхронизируются как IMAP keywords ($label1…, $Important, Gmail-ярлыки, Exchange categories); флаг с датой «к исполнению» → задача P0
Архив «В архив» (A): папка Archive (или Gmail «Все письма»), по годам опционально; автоархивация старых писем в локальный .mmx P0 (автоархивация — P1)
Отслеживание уведомления о прочтении (MDN, RFC 8098) и доставке (DSN); «ожидаю ответ» — напоминание, если нет ответа через N дней P1
Печать письмо/цепочка/список через Meridian Print; «Сохранить как PDF» P0
Шаблоны и слияние письма-шаблоны с полями {{имя}}; рассылка слиянием из Sheets/контактов (персонализированные письма, задержка между отправками) P1
Чтение вслух, доступность экранный диктор, клавиатурная навигация, высокий контраст (общие с Shell) P0
Карточка отправителя по наведению: контакт, последние письма, события, «добавить в контакты» P1
Списки рассылки обнаружение List-Unsubscribe → кнопка «Отписаться» (mailto/https, RFC 8058 one-click) P0
Квота и размер квота IMAP (GETQUOTA), размер папок локально, «Очистка» (большие письма) P1

2.3 Календарь

Функция Описание Приоритет
Хранение и протоколы iCalendar RFC 5545; iTIP RFC 5546 (REQUEST/REPLY/CANCEL/COUNTER/REFRESH) по почте (iMIP, RFC 6047); CalDAV RFC 4791 + scheduling RFC 6638; Google Calendar API; Microsoft Graph/EWS; локальные календари; подписки на ICS по URL (праздники, расписания) P0 (Google/MS API — P1)
Виды День, Рабочая неделя, Неделя, Месяц, Год (тепловая карта занятости), Повестка (список), «Расписание» (несколько календарей по строкам, P1); мини-календарь навигации; таймлайн с текущим временем P0
События заголовок, место (с картой-ссылкой), описание (форматированное), категории/цвет, участники, вложения, ссылка на видеозвонок (обнаружение в описании), «весь день», несколько дней, частное/занято/свободно/под вопросом/вне офиса, приватность P0
Повторения RRULE полностью (FREQ/INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY/BYYEARDAY/BYWEEKNO/BYMONTH/BYSETPOS/WKST), исключения EXDATE, добавления RDATE, изменения отдельных экземпляров (RECURRENCE-ID), «это и последующие» (разбиение серии), визуальный редактор правил + текст по-русски («каждый 2-й вторник месяца») P0
Напоминания VALARM (DISPLAY/EMAIL/AUDIO): несколько на событие, относительно начала/конца, абсолютные; всплывающие окна с «отложить» (5/10/15 мин, 1 ч, 1 д, «до начала»), системные уведомления; «напоминания по умолчанию» на календарь P0
Часовые пояса IANA tzdb (встроенная копия, обновления с пакетом; на Windows — сопоставление с Windows-зонами CLDR), события с TZID, «плавающее» время, показ второго пояса, предупреждение при переезде, VTIMEZONE при экспорте P0
Приглашения отправка приглашений (iMIP, вложение invite.ics + текстовое описание), ответы «Принять/Под вопросом/Отклонить» + предложить другое время (COUNTER), обработка ответов (обновление статуса участников), обновления/отмены (SEQUENCE), письмо-приглашение показывает карточку события с кнопками P0
Занятость (free/busy) планировщик: сетка участников × время, данные из CalDAV free-busy-query, Exchange/Graph getSchedule, iTIP VFREEBUSY; «найти время» — поиск первого окна по длительности и рабочим часам P1
Несколько календарей неограниченно, цвет, видимость, «только чтение», календарь по умолчанию, объединённый вид; группы календарей P0
Общие календари CalDAV sharing (RFC draft-pot-caldav-sharing/Apple), Google ACL, Exchange delegate; подписка на календарь коллеги; делегирование P1
Праздники по странам (встроенные ICS из holidays/<cc>.ics, обновляются с пакетом; РФ — производственный календарь с переносами), несколько стран, отображение в заголовках дней P0
Задачи в календаре показ задач со сроком в видах дня/недели, перетаскивание на другой день P1
Печать день/неделя/месяц/повестка, стили «ежедневник»; PDF P0
Погода в заголовках дней (опционально, через провайдера, только по согласию) P2
Рабочее время и выходные настройка на календарь; подсветка; учёт в «найти время» P0

2.4 Контакты

Функция Описание Приоритет
Хранение и протоколы vCard 3.0 (RFC 2426) и 4.0 (RFC 6350), CardDAV (RFC 6352), Google People API, Graph/EWS; локальные книги; LDAP-книги (RFC 4511, только чтение, автодополнение) P0 (LDAP — P1)
Карточка имя (структурированное, транслитерация, сортировочное), организация/должность, несколько e-mail/телефонов/адресов с типами, дни рождения/годовщины (→ календарь), фото, заметки, URL, мессенджеры (IMPP), связи (RELATED), категории, произвольные поля (X-), источник P0
Группы статические списки рассылки (vCard 4 KIND:group, CardDAV addressbook groups), динамические (по фильтру) P0 (динамические — P1)
Дубликаты поиск и слияние (алгоритм 5.9), ручной выбор полей при слиянии P0
Автосбор «собранные адреса» из отправленных/полученных (отдельная книга), предложение добавить в контакты P0
Импорт CSV (Outlook/Google/Thunderbird/Яндекс-схемы с автосопоставлением столбцов), LDIF, vCard, Outlook PST (через импорт почты), macOS Contacts (через vCard-экспорт), Windows Contacts P0
Экспорт vCard 3/4 (один файл или по карточке), CSV, LDIF P0
Печать карточки, список, телефонная книга P1
Карты ссылка на адрес в картах (OpenStreetMap/Яндекс/Google по выбору) P1

2.5 Задачи

Функция Описание Приоритет
Списки несколько списков (локальные, CalDAV VTODO, Google Tasks, Microsoft To Do через Graph, Exchange Tasks), цвета P0 (Google/MS — P1)
Задача название, заметки, срок, дата начала, напоминание, приоритет, статус (нужно/в работе/выполнена/отменена), % выполнения, категории, подзадачи (RELATED-TO PARENT / иерархия), повторения (RRULE для VTODO: «после выполнения»/«по расписанию»), вложения, ссылка на письмо/событие/документ P0
Виды Мои задачи · Сегодня · Запланировано · Просроченные · По спискам · Выполненные; сортировка/группировка; быстрый ввод с разбором («завтра 15:00 позвонить Ивану !высокий #работа») P0
Связь с письмами «Создать задачу из письма» (ссылка на письмо, тема → название), флаг письма с датой = задача в списке «Помеченные письма» P0
Делегирование назначение задачи участнику (iTIP для VTODO, Exchange task request) P2

2.6 Импорт и экспорт данных

Направление Формат Описание Приоритет
Импорт PST/OST (Unicode и ANSI) собственный парсер MS-PST на Rust (раздел 6.3): папки, письма (MAPI-свойства → MIME), вложения, контакты, календарь, задачи, заметки; OST — чтение без сервера (R*) P0 (OST — P1)
Импорт mbox, mboxrd, Maildir папки Thunderbird/Apple Mail (.mbox пакеты macOS) P0
Импорт EML, MSG (OLE/CFB, MAPI) файлы и папки с ними; .msg через codecs/mail/msg (CFB + MS-OXMSG) P0
Импорт ICS, VCF, LDIF, CSV календари, контакты P0
Импорт Профили Thunderbird/Outlook/Apple Mail мастер: аккаунты (без паролей), папки, фильтры (Thunderbird msgFilterRules.dat), подписи P1
Экспорт .mmx архив выбранные папки/календари/книги/списки с индексом (раздел 6.1) P0
Экспорт EML/mbox/ICS/VCF/PDF/CSV по выбору; письмо → PDF через Print; список писем → CSV P0
Экспорт PST запись PST для обмена с Outlook P2

2.7 Прочее

Единые параметры (ML-16), темы оформления писем, звуки, переключение плотности, «Фокус-режим» (только список и чтение), мультиокно (письмо/событие в отдельном окне), сочетания Gmail-стиля (j/k/e/#) как альтернативная схема, Assist (P2: краткое содержание цепочки, черновик ответа, предложение времени встречи), командная палитра, MeridianScript (раздел 5.12), печать, доступность.


3. Экраны

Каркас окна — по разделу 5.3 библии. Модули переключаются в левой панели (Почта · Календарь · Контакты · Задачи, Ctrl+1…4), ribbon зависит от модуля: Почта — Файл · Главная · Отправка и получение · Папка · Вид · Скрипты; Календарь — Файл · Главная · Вид · Скрипты; окно письма — Сообщение · Вставка · Формат · Параметры · Рецензирование.

ID Экран Описание
ML-01 Главное окно почты Слева: переключатель модулей (иконки) + дерево папок по аккаунтам (избранные вверху, «Все входящие», счётчики, квота); центр: список писем (поиск сверху, фильтры «Непрочитанные/Помеченные/С вложениями/От контактов», группировка по дате, карточки: отправитель, тема, фрагмент, время, значки вложения/флага/категории/шифрования, аватар), справа: панель чтения. Переключатель «Разговоры». Строка состояния: аккаунт · «Синхронизировано 19:12» · очередь отправки (N) · индексация · офлайн
ML-02 Чтение письма Заголовок: тема, отправитель с карточкой, получатели (свёрнуто), дата с поясом, категории; баннеры (внешние изображения заблокированы, антифишинг, список рассылки → отписаться, S/MIME/PGP статус, приглашение в календарь с кнопками); тело в безопасном рендере; вложения (плитки с предпросмотром); панель быстрых действий (ответить/всем/переслать/архив/удалить/флаг/спам/переместить/категория/в задачу/в Notes/печать/источник); цепочка — раскрывающиеся письма; «Показать оригинал»/«Показать как текст»
ML-03 Составление письма Окно или встроенно (ответ в панели чтения); поля От (идентификация), Кому/Копия/Скрытая с чипами адресов и автодополнением, Тема; панель вложений; редактор Write (ribbon «Сообщение»: шрифт, абзац, вложить файл/из облака, подпись, шаблон, важность, S/MIME/PGP, уведомления, отложить; «Формат»: стили, таблицы, изображения, ссылки); нижняя строка: проверка орфографии, размер письма, «Отправить» (Ctrl+Enter), ▾ «Отправить позже», «Сохранить черновик». Простой текстовый режим
ML-04 Календарь — День Левая панель: мини-календарь, список календарей с флажками/цветами, «Задачи на день»; шкала времени с рабочими часами, события-плитки с цветом календаря, перетаскивание/растягивание; «весь день» сверху; второй часовой пояс слева
ML-05 Календарь — Неделя / Рабочая неделя 7/5 столбцов, та же сетка; перетаскивание между днями; номера недель; праздники в заголовке
ML-06 Календарь — Месяц Сетка 6×7 с «ещё N» и всплывающим списком, перетаскивание дней; задачи со сроком
ML-07 Календарь — Повестка Список событий по дням на N недель вперёд, группы «Сегодня/Завтра/Эта неделя», поиск по событиям
ML-08 Календарь — Год 12 мини-месяцев, тепловая карта занятости, щелчок → день
ML-09 Событие Окно: заголовок, календарь, начало/конец с поясом, «весь день», повторение (редактор: шаблоны + конструктор + описание по-русски), место, участники (с индикатором ответа), занятость/приватность, напоминания, категории, вложения, описание (Write-редактор), ссылка на звонок; вкладка «Планирование» (free/busy); кнопки «Сохранить», «Отправить приглашение», «Удалить» (серия/экземпляр/это и последующие)
ML-10 Приглашение и ответ Карточка в письме: событие, участники, конфликт с календарём («пересекается с…»), кнопки Принять/Под вопросом/Отклонить (с отправкой ответа или без), «Предложить другое время»; у организатора — обновление статусов, «Отправить обновление»
ML-11 Контакты Слева: книги и группы; центр: список/плитки с поиском и алфавитным индексом; справа: карточка. Панель «Дубликаты» (ML-24)
ML-12 Карточка контакта Фото, имя, организация, поля с типами (+ добавить поле), группы, заметки, «Последние письма/события», действия: написать, встреча, звонок (tel:), карта, «Отправить vCard»; режим редактирования с валидацией e-mail/телефона (E.164)
ML-13 Задачи Слева: умные списки + списки; центр: задачи (чекбокс, название, срок, приоритет, подзадачи свёрнуты), быстрый ввод сверху; справа: подробности задачи (все поля, подзадачи, вложения, связанное письмо)
ML-14 Мастер учётной записи Шаг 1: имя, e-mail, пароль/«Войти через …» (OAuth); Шаг 2: автоопределение (прогресс, найденные параметры IMAP/SMTP/Exchange, «Изменить вручную»); Шаг 3: что синхронизировать (почта: все/за N дней; календарь; контакты; задачи), папки; Шаг 4: готово (импорт из другого клиента). Ручной режим: сервер/порт/шифрование/аутентификация с проверкой
ML-15 Правила и фильтры Список правил (вкл/выкл, порядок, аккаунт, статистика срабатываний); редактор: условия (все/любое; строка условия: поле — оператор — значение), действия (список), «применить к папке сейчас», «журнал»; импорт правил Thunderbird/Outlook; переключатель «серверное правило (Sieve/Exchange)»
ML-16 Параметры Mail Вкладки: Учётные записи (список, идентификации, синхронизация, OAuth-состояние), Чтение (внешние изображения, пометка прочитанным, цепочки, предпросмотр), Составление (формат, цитирование, подписи, шаблоны, отменить отправку), Безопасность (S/MIME, OpenPGP, антифишинг, спам), Календарь (пояса, рабочее время, праздники, напоминания по умолчанию), Контакты (сортировка, автосбор), Задачи, Уведомления, Автономная работа (кэш, размер), Поиск (индекс, перестроить)
ML-17 Поиск Поле с подсказками операторов, панель фильтров (аккаунт, папка, дата, от/кому, вложения, флаги, размер), результаты с подсветкой, «искать на сервере» (когда кэш неполный), сохранить как виртуальную папку
ML-18 Импорт и экспорт Мастер: источник (PST/OST, mbox, Maildir, EML/MSG, ICS, VCF, CSV, LDIF, профиль Thunderbird/Outlook/Apple Mail) → сопоставление (папки → аккаунт/локальные, столбцы CSV → поля) → прогресс с журналом; экспорт: выбор объектов → формат → файл
ML-19 Подписи и шаблоны Редактор подписей (Write, изображения, vCard-вложение) по идентификациям; шаблоны писем с полями {{…}}; «ответы по умолчанию»
ML-20 Безопасность и сертификаты Менеджер S/MIME-сертификатов (свои, чужие, доверие), менеджер OpenPGP-ключей (создать/импорт/экспорт/отзыв/Autocrypt-состояние по контактам), исключения TLS, журнал проверок
ML-21 Уведомление Системный тост: отправитель, тема, фрагмент, действия «Архив/Удалить/Ответить»; напоминание события: «Отложить ▾ / Закрыть / Открыть»
ML-22 Печать Предпросмотр Meridian Print: письмо (с заголовками/без, вложения списком), цепочка, список, календарь (стили), контакты
ML-23 Планировщик (free/busy) Сетка участников × часы с цветами занят/под вопросом/вне офиса/нет данных; «Автоподбор» (следующее свободное окно); выбор длительности; перетаскивание окна встречи
ML-24 Дубликаты контактов Пары/группы кандидатов с оценкой совпадения; сравнение по полям, выбор значений, «Объединить», «Не дубликат» (запоминается)
ML-25 Автономный режим и очередь Панель: состояние соединений по аккаунтам, очередь отправки (письма с таймером/ошибкой/«отправить сейчас»/«отменить»), очередь действий (N), «Работать автономно»
ML-26 Карточка отправителя (всплывающая) Имя, адрес, организация, фото; последние 5 писем и ближайшие события; кнопки «Написать», «Встреча», «В контакты», «Заблокировать»
ML-27 Редактор повторений Шаблоны (ежедневно/еженедельно/ежемесячно/ежегодно/по будням), конструктор (каждые N, по дням недели, по числам, по позиции «второй вторник»), окончание (никогда/после N/до даты), предпросмотр ближайших 10 дат, текст правила
ML-28 Мастер слияния (рассылка) Источник (Sheets/контакты/CSV), шаблон с полями, предпросмотр по записям, параметры отправки (задержка, через какую идентификацию), запуск в очередь

4. Модель данных

4.1 Хранилище профиля

<profile>/mail/
├── mail.db                      ← SQLite (схема ниже), WAL
├── store/<account>/<folder-id>/ ← тела писем: <uid>.eml.zst (maildir-подобно: один файл = одно письмо)
│   └── tmp/                     ← незавершённые записи (переименование атомарно)
├── parts/<sha256[0..2]>/<sha256>  ← контент-адресуемые вложения/части > 64 КБ (дедупликация)
├── fts/                         ← индекс tantivy
├── spam/tokens.db               ← байесовские токены (SQLite)
├── cache/thumbs/                ← предпросмотр вложений
└── logs/protocol-<account>.log  ← журнал протокола (по включению, без тел)

Тела писем хранятся как исходный RFC 5322 поток (zstd, уровень 3): это гарантирует round-trip, подписи S/MIME/PGP проверяются по оригиналу; части > 64 КБ (вложения) вынимаются в parts/ и заменяются в .eml.zst ссылкой X-Meridian-Part: sha256 только в локальной копии (при экспорте собираются обратно).

4.2 Схема SQLite mail.db

PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA application_id = 0x4D4D5831; -- 'MMX1'

CREATE TABLE accounts (
  id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('imap','pop3','ews','graph','gmail-api','jmap','local')),
  color TEXT, ord INTEGER, enabled INTEGER DEFAULT 1,
  incoming_host TEXT, incoming_port INTEGER, incoming_tls TEXT CHECK(incoming_tls IN ('tls','starttls','none')),
  outgoing_host TEXT, outgoing_port INTEGER, outgoing_tls TEXT, auth TEXT CHECK(auth IN ('password','oauth2','ntlm','none')),
  credential_ref TEXT,                       -- ключ в хранилище ОС / key in OS keystore (никогда сам секрет)
  oauth_provider TEXT, sync_days INTEGER DEFAULT 0, sync_bodies INTEGER DEFAULT 1,
  capabilities TEXT,                         -- JSON: CONDSTORE, QRESYNC, MOVE, IDLE, ...
  delimiter TEXT, namespaces TEXT, quota_used INTEGER, quota_total INTEGER, last_sync INTEGER, last_error TEXT
);
CREATE TABLE identities (
  id INTEGER PRIMARY KEY, account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
  name TEXT, email TEXT NOT NULL, reply_to TEXT, signature_html TEXT, signature_text TEXT,
  is_default INTEGER DEFAULT 0, smime_cert_ref TEXT, pgp_key_fpr TEXT, prefer_encrypt INTEGER DEFAULT 0
);
CREATE TABLE folders (
  id INTEGER PRIMARY KEY, account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE,
  parent_id INTEGER REFERENCES folders(id), name TEXT NOT NULL, full_name TEXT NOT NULL,   -- IMAP mailbox name (UTF-7 → UTF-8)
  role TEXT CHECK(role IN ('inbox','sent','drafts','trash','junk','archive','all','outbox','custom')),
  uidvalidity INTEGER, uidnext INTEGER, highestmodseq INTEGER, exists_count INTEGER, unseen_count INTEGER,
  subscribed INTEGER DEFAULT 1, favorite INTEGER DEFAULT 0, ord INTEGER, sync_enabled INTEGER DEFAULT 1,
  last_sync INTEGER, local_only INTEGER DEFAULT 0, color TEXT,
  UNIQUE(account_id, full_name)
);
CREATE TABLE messages (
  id INTEGER PRIMARY KEY, folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
  uid INTEGER,                               -- IMAP UID (NULL для локальных/POP) / NULL for local/POP
  modseq INTEGER, message_id TEXT, in_reply_to TEXT, refs TEXT,          -- References (JSON array)
  thread_id INTEGER REFERENCES threads(id),
  subject TEXT, subject_norm TEXT,           -- без Re:/Fwd: / normalized subject
  from_name TEXT, from_addr TEXT, to_json TEXT, cc_json TEXT, bcc_json TEXT, reply_to_json TEXT,
  date INTEGER, received INTEGER, size INTEGER, snippet TEXT,
  flags INTEGER NOT NULL DEFAULT 0,          -- bit: 1 seen, 2 answered, 4 flagged, 8 deleted, 16 draft, 32 forwarded, 64 junk, 128 phishing
  keywords TEXT,                             -- JSON: IMAP keywords ($label1, custom)
  has_attachments INTEGER DEFAULT 0, priority INTEGER DEFAULT 3, encryption TEXT, signature_state TEXT,
  body_state TEXT CHECK(body_state IN ('none','headers','partial','full')) DEFAULT 'headers',
  body_path TEXT, pop_uidl TEXT, list_id TEXT, list_unsubscribe TEXT,
  spam_score REAL, auth_results TEXT, pinned INTEGER DEFAULT 0, snooze_until INTEGER,
  UNIQUE(folder_id, uid)
);
CREATE INDEX messages_folder_date ON messages(folder_id, date DESC);
CREATE INDEX messages_msgid ON messages(message_id);
CREATE INDEX messages_thread ON messages(thread_id);
CREATE INDEX messages_flags ON messages(folder_id, flags);
CREATE TABLE parts (
  id INTEGER PRIMARY KEY, message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
  section TEXT NOT NULL,                     -- IMAP section: "1", "1.2", "2"
  mime_type TEXT, filename TEXT, content_id TEXT, disposition TEXT, size INTEGER, charset TEXT, encoding TEXT,
  sha256 TEXT, store_path TEXT, fetched INTEGER DEFAULT 0, is_attachment INTEGER DEFAULT 0, text_extracted INTEGER DEFAULT 0
);
CREATE INDEX parts_msg ON parts(message_id);
CREATE TABLE threads (
  id INTEGER PRIMARY KEY, account_id INTEGER, root_message_id TEXT, subject_norm TEXT,
  last_date INTEGER, count INTEGER, unseen INTEGER, flagged INTEGER, participants TEXT
);
CREATE INDEX threads_last ON threads(account_id, last_date DESC);
CREATE TABLE labels (id INTEGER PRIMARY KEY, account_id INTEGER, name TEXT, color TEXT, keyword TEXT, remote_id TEXT, ord INTEGER);
CREATE TABLE message_labels (message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE, label_id INTEGER REFERENCES labels(id) ON DELETE CASCADE, PRIMARY KEY(message_id, label_id));
CREATE TABLE rules (
  id INTEGER PRIMARY KEY, account_id INTEGER, name TEXT, enabled INTEGER DEFAULT 1, ord INTEGER,
  match_all INTEGER DEFAULT 1, conditions TEXT NOT NULL, actions TEXT NOT NULL,        -- JSON
  run_on TEXT DEFAULT 'incoming', server_side TEXT, hits INTEGER DEFAULT 0, last_hit INTEGER
);
CREATE TABLE outbox (
  id INTEGER PRIMARY KEY, identity_id INTEGER REFERENCES identities(id), draft_message_id INTEGER REFERENCES messages(id),
  eml_path TEXT NOT NULL, recipients TEXT NOT NULL, send_after INTEGER, attempts INTEGER DEFAULT 0, next_attempt INTEGER,
  state TEXT CHECK(state IN ('scheduled','queued','sending','sent','failed','cancelled')) DEFAULT 'queued',
  last_error TEXT, created INTEGER, undo_until INTEGER
);
CREATE TABLE pending_ops (                   -- очередь действий для сервера / offline action queue
  id INTEGER PRIMARY KEY, account_id INTEGER, kind TEXT NOT NULL,   -- flags|move|copy|delete|append|expunge|label|create_folder|...
  payload TEXT NOT NULL, created INTEGER, attempts INTEGER DEFAULT 0, last_error TEXT
);
CREATE TABLE sync_state (folder_id INTEGER PRIMARY KEY REFERENCES folders(id) ON DELETE CASCADE,
  mode TEXT CHECK(mode IN ('qresync','condstore','plain')), last_uid INTEGER, last_modseq INTEGER, last_full_check INTEGER, in_progress INTEGER DEFAULT 0);

-- Календарь / Calendar
CREATE TABLE calendars (
  id INTEGER PRIMARY KEY, account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE, name TEXT NOT NULL, color TEXT,
  kind TEXT CHECK(kind IN ('local','caldav','google','graph','ews','subscription')), url TEXT, ctag TEXT, sync_token TEXT,
  visible INTEGER DEFAULT 1, read_only INTEGER DEFAULT 0, is_default INTEGER DEFAULT 0, default_alarms TEXT, tz TEXT, ord INTEGER, last_sync INTEGER
);
CREATE TABLE events (
  id INTEGER PRIMARY KEY, calendar_id INTEGER NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
  uid TEXT NOT NULL, recurrence_id TEXT,     -- NULL для мастера серии / NULL for the master
  etag TEXT, href TEXT, sequence INTEGER DEFAULT 0,
  summary TEXT, location TEXT, description TEXT, description_html TEXT, url TEXT, categories TEXT, color TEXT,
  dtstart INTEGER NOT NULL, dtstart_tz TEXT, dtend INTEGER, dtend_tz TEXT, all_day INTEGER DEFAULT 0, floating INTEGER DEFAULT 0,
  rrule TEXT, rdates TEXT, exdates TEXT, status TEXT, transp TEXT, class TEXT, priority INTEGER,
  organizer TEXT, is_organizer INTEGER, my_partstat TEXT, created INTEGER, last_modified INTEGER, dtstamp INTEGER,
  ical BLOB,                                 -- исходный VEVENT для round-trip / original VEVENT
  UNIQUE(calendar_id, uid, recurrence_id)
);
CREATE INDEX events_range ON events(calendar_id, dtstart, dtend);
CREATE TABLE event_instances (               -- развёрнутые экземпляры (кэш) / expanded occurrences cache
  event_id INTEGER REFERENCES events(id) ON DELETE CASCADE, start INTEGER NOT NULL, end INTEGER NOT NULL,
  is_override INTEGER DEFAULT 0, cancelled INTEGER DEFAULT 0, PRIMARY KEY(event_id, start)
);
CREATE INDEX instances_range ON event_instances(start, end);
CREATE TABLE attendees (event_id INTEGER REFERENCES events(id) ON DELETE CASCADE, email TEXT, name TEXT, role TEXT, partstat TEXT, rsvp INTEGER, cutype TEXT, delegated_to TEXT, PRIMARY KEY(event_id, email));
CREATE TABLE alarms (id INTEGER PRIMARY KEY, event_id INTEGER REFERENCES events(id) ON DELETE CASCADE, task_id INTEGER, action TEXT, trigger_rel INTEGER, trigger_abs INTEGER, related TEXT, description TEXT, acknowledged INTEGER, snoozed_until INTEGER);
CREATE TABLE freebusy_cache (email TEXT, start INTEGER, end INTEGER, kind TEXT, fetched INTEGER, PRIMARY KEY(email, start, end));

-- Контакты / Contacts
CREATE TABLE addressbooks (id INTEGER PRIMARY KEY, account_id INTEGER, name TEXT, kind TEXT CHECK(kind IN ('local','carddav','google','graph','ews','ldap','collected')), url TEXT, ctag TEXT, sync_token TEXT, read_only INTEGER DEFAULT 0, ord INTEGER);
CREATE TABLE contacts (
  id INTEGER PRIMARY KEY, addressbook_id INTEGER REFERENCES addressbooks(id) ON DELETE CASCADE, uid TEXT, etag TEXT, href TEXT,
  kind TEXT DEFAULT 'individual', fn TEXT, family TEXT, given TEXT, additional TEXT, prefix TEXT, suffix TEXT, nickname TEXT,
  sort_key TEXT, org TEXT, title TEXT, bday TEXT, anniversary TEXT, note TEXT, photo_path TEXT, categories TEXT,
  vcard BLOB, vcard_version TEXT, created INTEGER, modified INTEGER, UNIQUE(addressbook_id, uid)
);
CREATE INDEX contacts_sort ON contacts(sort_key);
CREATE TABLE contact_emails (contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE, email TEXT NOT NULL, email_norm TEXT NOT NULL, type TEXT, pref INTEGER DEFAULT 0);
CREATE INDEX contact_emails_norm ON contact_emails(email_norm);
CREATE TABLE contact_phones (contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE, phone TEXT, phone_e164 TEXT, type TEXT, pref INTEGER DEFAULT 0);
CREATE INDEX contact_phones_e164 ON contact_phones(phone_e164);
CREATE TABLE contact_addresses (contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE, type TEXT, street TEXT, city TEXT, region TEXT, postal TEXT, country TEXT);
CREATE TABLE contact_groups (id INTEGER PRIMARY KEY, addressbook_id INTEGER, uid TEXT, name TEXT, filter TEXT);
CREATE TABLE contact_group_members (group_id INTEGER REFERENCES contact_groups(id) ON DELETE CASCADE, contact_id INTEGER REFERENCES contacts(id) ON DELETE CASCADE, PRIMARY KEY(group_id, contact_id));
CREATE TABLE not_duplicates (a INTEGER, b INTEGER, PRIMARY KEY(a, b));

-- Задачи / Tasks
CREATE TABLE task_lists (id INTEGER PRIMARY KEY, account_id INTEGER, name TEXT, color TEXT, kind TEXT CHECK(kind IN ('local','caldav','google','graph','ews','flagged')), url TEXT, ctag TEXT, sync_token TEXT, ord INTEGER);
CREATE TABLE tasks (
  id INTEGER PRIMARY KEY, list_id INTEGER REFERENCES task_lists(id) ON DELETE CASCADE, uid TEXT, etag TEXT, href TEXT,
  parent_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, summary TEXT NOT NULL, description TEXT,
  dtstart INTEGER, due INTEGER, completed INTEGER, status TEXT, percent INTEGER DEFAULT 0, priority INTEGER DEFAULT 0,
  categories TEXT, rrule TEXT, rrule_after_completion INTEGER DEFAULT 0, ord INTEGER,
  linked_message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL, linked_event_id INTEGER, ical BLOB, created INTEGER, modified INTEGER
);
CREATE INDEX tasks_due ON tasks(due);

-- Вложения (общие) / Attachments registry
CREATE TABLE attachments (sha256 TEXT PRIMARY KEY, size INTEGER, mime_type TEXT, refcount INTEGER DEFAULT 0, text_path TEXT, thumb_path TEXT);

-- Индекс / Index bookkeeping
CREATE TABLE fts_state (message_id INTEGER PRIMARY KEY, indexed_hash BLOB, indexed_at INTEGER);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT);

4.3 Структуры ядра (Rust)

// kernel/crates/meridian-mail/src/model.rs
pub struct Envelope {
    pub message_id: Option<String>, pub in_reply_to: Option<String>, pub references: Vec<String>,
    pub subject: String, pub from: Vec<Mailbox>, pub to: Vec<Mailbox>, pub cc: Vec<Mailbox>,
    pub date: Option<DateTime<FixedOffset>>, pub size: u64,
}
pub struct Mailbox { pub name: Option<String>, pub addr: String }

bitflags! { pub struct Flags: u32 { const SEEN=1; const ANSWERED=2; const FLAGGED=4; const DELETED=8; const DRAFT=16;
            const FORWARDED=32; const JUNK=64; const PHISHING=128; } }

pub struct MimePart {
    pub section: String, pub ctype: Mime, pub params: BTreeMap<String,String>,
    pub disposition: Option<Disposition>, pub filename: Option<String>, pub content_id: Option<String>,
    pub encoding: Transfer, pub charset: Option<String>, pub size: u64,
    pub body: PartBody,                      // Inline(Bytes) | Stored(sha256) | NotFetched
    pub children: Vec<MimePart>,             // для multipart / for multipart
}

pub struct Event {
    pub uid: String, pub recurrence_id: Option<DateTimeOrDate>, pub sequence: u32,
    pub summary: String, pub dtstart: DateTimeOrDate, pub dtend: Option<DateTimeOrDate>, pub duration: Option<Duration>,
    pub rrule: Option<RRule>, pub rdate: Vec<DateTimeOrDate>, pub exdate: Vec<DateTimeOrDate>,
    pub attendees: Vec<Attendee>, pub organizer: Option<Attendee>, pub alarms: Vec<Alarm>,
    pub status: EventStatus, pub transp: Transp, pub class: Class, pub extra: Vec<IcalProperty>,   // round-trip
}

pub enum DateTimeOrDate { Date(NaiveDate), Floating(NaiveDateTime), Utc(DateTime<Utc>), Zoned { local: NaiveDateTime, tzid: TzId } }

4.4 Архив .mmx

archive.mmx (ZIP, mimetype = application/vnd.meridian.mail)
├── mimetype
├── manifest.xml                 ← состав, диапазоны дат, аккаунты (без секретов), версия
├── meta.xml
├── mail/<account>/<Folder/Sub>/<uid-or-ulid>.eml      ← письма как есть (RFC 5322), stored
├── calendar/<calendar-name>.ics ← VCALENDAR с VTIMEZONE
├── contacts/<book-name>.vcf     ← vCard 4.0 (фото встроены)
├── tasks/<list-name>.ics        ← VTODO
├── rules.xml, signatures/, categories.xml
├── index.sqlite                 ← копия подмножества mail.db для быстрого открытия (опц.)
└── thumbnail.png

Архив открывается в Mail как «Архив (только чтение)» без импорта — папки/события/контакты доступны для поиска; «Импортировать в аккаунт» — копирует. Автоархивация пишет по одному .mmx на год.


5. Алгоритмы и скрипты

5.1 Синхронизация IMAP

Три режима по возможностям сервера: QRESYNC (RFC 7162, лучший), CONDSTORE (изменения флагов по MODSEQ, удаления — через полную выборку UID), plain (без расширений). Инвариант: локальная БД — кэш сервера; локальные действия сначала попадают в pending_ops, применяются локально (оптимистично) и воспроизводятся на сервере; сервер — источник истины после воспроизведения.

/// Инкрементальная синхронизация папки / Incremental folder sync
pub async fn sync_folder(cx: &mut ImapSession, db: &Db, f: &Folder) -> Result<()> {
    // 1. Воспроизвести локальные операции (флаги, перемещения) до чтения состояния сервера
    //    / Replay local pending ops before reading server state
    replay_pending_ops(cx, db, f).await?;

    // 2. SELECT с QRESYNC, если можем / SELECT with QRESYNC when possible
    let st = db.sync_state(f.id)?;
    let sel = if cx.caps.qresync && st.mode == Qresync && st.last_modseq > 0 {
        cx.select_qresync(&f.full_name, f.uidvalidity, st.last_modseq).await?
    } else { cx.select(&f.full_name).await? };

    // 3. UIDVALIDITY изменился → кэш недействителен / UIDVALIDITY changed → drop cache
    if sel.uidvalidity != f.uidvalidity {
        db.invalidate_folder(f.id)?;                      // тела остаются по message_id для повторного связывания
        return Box::pin(full_sync(cx, db, f, sel)).await;
    }

    match st.mode {
        Qresync => {
            // Сервер уже прислал VANISHED (EARLIER) и FETCH FLAGS для изменённых / server pushed VANISHED + FLAGS
            db.apply_vanished(f.id, &sel.vanished)?;
            db.apply_flag_updates(f.id, &sel.flag_updates)?;
        }
        Condstore => {
            // Изменённые флаги / changed flags since last MODSEQ
            let changed = cx.uid_fetch("1:*", "(FLAGS)", Some(st.last_modseq)).await?;   // CHANGEDSINCE
            db.apply_flag_updates(f.id, &changed)?;
            // Удаления — сравнением множеств UID (дёшево: UID SEARCH ALL / ESEARCH) / deletions by UID set diff
            let server_uids = cx.uid_search_all().await?;
            db.delete_missing(f.id, &server_uids)?;
        }
        Plain => {
            let server = cx.uid_fetch("1:*", "(FLAGS)", None).await?;
            db.delete_missing(f.id, &server.uids())?;
            db.apply_flag_updates(f.id, &server)?;
        }
    }

    // 4. Новые письма: UID > last_uid → заголовки пачками по 500 / new messages: headers in batches of 500
    let new_range = format!("{}:*", st.last_uid + 1);
    let envs = cx.uid_fetch(&new_range, "(UID FLAGS MODSEQ RFC822.SIZE BODYSTRUCTURE ENVELOPE BODY.PEEK[HEADER.FIELDS (MESSAGE-ID IN-REPLY-TO REFERENCES LIST-ID LIST-UNSUBSCRIBE AUTHENTICATION-RESULTS X-PRIORITY)])", None).await?;
    for env in envs.iter().filter(|e| e.uid > st.last_uid) {
        let msg_id = db.upsert_message(f.id, env)?;
        thread_insert(db, msg_id)?;                         // JWZ, инкрементально / incremental threading
        rules_enqueue(db, msg_id)?;                         // правила на новые письма / rules for new mail
        if f.policy.fetch_bodies(env) { body_queue.push(f.id, env.uid, Priority::Background); }
    }

    // 5. Сохранить курсор / persist cursor
    db.set_sync_state(f.id, SyncState { last_uid: sel.uidnext - 1, last_modseq: sel.highestmodseq, .. })?;
    Ok(())
}

Дополнительно: IDLE на INBOX и избранных папках (до 3 соединений на аккаунт, переподключение каждые 25 мин), NOTIFY при наличии; полная сверка папки раз в 24 ч (last_full_check); при ошибке [CLOSED]/разрыве — экспоненциальный backoff 2…300 с; тела — BODY.PEEK[] или по частям (BODY.PEEK[1.2]) для больших писем, вложения — по требованию; COMPRESS=DEFLATE при наличии. Конфликты флагов: локальная операция в pending_ops выигрывает у серверного значения, прочитанного до её воспроизведения; после воспроизведения — значение сервера. Перемещение: UID MOVE (или COPY+\Deleted+UID EXPUNGE), локально письмо сразу переносится с временным uid = NULL и привязывается по Message-ID при следующей синхронизации целевой папки (UIDPLUS COPYUID даёт UID сразу). Gmail: папки = ярлыки, X-GM-LABELS, X-GM-THRID, X-GM-MSGID.

5.2 Цепочки — алгоритм JWZ

/// Построение цепочек (Zawinski) / JWZ threading
pub fn build_threads(msgs: &[MsgRef]) -> Vec<ContainerId> {
    let mut table: HashMap<String, ContainerId> = HashMap::new();   // id_table
    let mut arena = Arena::new();                                    // контейнеры: msg: Option<MsgRef>, parent, children
    // 1. Для каждого письма / for each message
    for m in msgs {
        let mid = m.message_id.clone().unwrap_or_else(|| format!("<missing-{}>", m.db_id)); // синтетический id
        let c = *table.entry(mid.clone()).or_insert_with(|| arena.new_container(None));
        if arena[c].msg.is_some() { /* дубликат Message-ID → новый контейнер */ let d = arena.new_container(Some(m.clone())); arena.attach(c, d); continue; }
        arena[c].msg = Some(m.clone());
        // 1B. References по цепочке: каждый ссылается на следующий / link References chain
        let mut prev: Option<ContainerId> = None;
        for r in m.references.iter().chain(m.in_reply_to.iter()) {     // In-Reply-To добавляется как последний
            let rc = *table.entry(r.clone()).or_insert_with(|| arena.new_container(None));
            if let Some(p) = prev { if rc != p && !arena.reachable(rc, p) && arena[rc].parent.is_none() { arena.attach(p, rc); } }
            prev = Some(rc);
        }
        // 1C. Родитель текущего — последний из References; снять старого родителя / set parent = last reference
        if let Some(p) = prev { if p != c && !arena.reachable(c, p) { arena.detach(c); arena.attach(p, c); } }
    }
    // 2. Корни — контейнеры без родителя / roots = no parent
    let mut roots: Vec<_> = arena.ids().filter(|&c| arena[c].parent.is_none()).collect();
    // 4. Удалить пустые контейнеры / prune empty containers
    roots = roots.into_iter().flat_map(|r| prune_empty(&mut arena, r)).collect();
    // 5. Группировка по теме (без Re:/Fwd:/[list]) / group by normalized subject
    let mut by_subject: HashMap<String, ContainerId> = HashMap::new();
    for &r in &roots {
        let subj = normalized_subject(&arena, r);
        if subj.is_empty() { continue; }
        match by_subject.get(&subj) {
            None => { by_subject.insert(subj, r); }
            Some(&other) => {
                // предпочитаем контейнер без сообщения или с темой без Re: / prefer empty or non-reply root
                let (keep, merge) = choose_root(&arena, other, r);
                merge_roots(&mut arena, keep, merge); by_subject.insert(subj, keep);
            }
        }
    }
    roots.retain(|r| arena[*r].parent.is_none());
    // 6. Сортировка детей по дате / sort children by date
    for r in &roots { arena.sort_by_date(*r); }
    roots
}

Инкрементальный вариант (thread_insert): для нового письма ищем контейнеры по References/In-Reply-To в messages.message_id (индекс), затем по subject_norm среди корней за 30 дней; обновляем threads (count/unseen/last_date). Нормализация темы: срезаем префиксы Re:, RE:, Fwd:, FW:, Отв:, Ответ:, Пересл:, AW:, SV:, [список], (fwd) рекурсивно, схлопываем пробелы, нижний регистр.

5.3 Разворачивание RRULE

Реализация RFC 5545 §3.3.10 (совместима с python-dateutil/rrule.js на тестовом корпусе 2 000 правил).

expand(event, window[ws, we]) -> [instances]:
  tz = event.dtstart.tzid or floating/UTC
  dates = ∅
  if rrule:
    gen = iterate(rrule, dtstart):                   // генератор по частотам / frequency generator
      for period in periods(FREQ, INTERVAL, from dtstart):     // день/неделя/месяц/год
        set = candidates(period):
          YEARLY:  BYMONTH ⊗ BYWEEKNO ⊗ BYYEARDAY ⊗ BYMONTHDAY ⊗ BYDAY (с ординалами, WKST)
          MONTHLY: BYMONTHDAY ⊗ BYDAY (с ординалами: 2TU, -1FR), фильтр BYMONTH
          WEEKLY:  BYDAY (дни недели от WKST), фильтр BYMONTH
          DAILY/HOURLY/MINUTELY/SECONDLY: BYHOUR/BYMINUTE/BYSECOND как расширение, остальные — фильтры
          если ни одного BYxxx для частоты → день dtstart (день месяца, или день недели для WEEKLY)
        set = apply_time(set, BYHOUR/BYMINUTE/BYSECOND or dtstart time)
        set = sort(set); if BYSETPOS: set = select_positions(set, BYSETPOS)
        for d in set: if d >= dtstart: yield d
      // ограничение: не более 10 000 пустых периодов подряд (защита от вечных пустых правил)
    for d in gen:
      if UNTIL and d > UNTIL: break
      count += 1; if COUNT and count > COUNT: break      // COUNT считает все экземпляры, включая EXDATE
      dates += d
      if d > we + max_duration: break                    // окно / window cutoff
  else: dates = {dtstart}
  dates += RDATE                                         // добавления / additions
  dates −= EXDATE                                        // исключения (сравнение в UTC с учётом DATE vs DATE-TIME)
  // переопределения экземпляров / instance overrides
  for ov in overrides(event.uid):                        // VEVENT с RECURRENCE-ID
    if ov.range == THISANDFUTURE: dates = {d in dates | d < ov.recurrence_id} ∪ expand(ov shifted)
    else: dates −= {ov.recurrence_id}; instances += instance(ov)
  for d in dates: instances += { start: localize(d, tz), end: start + duration(event), cancelled: false }
  return [i in instances | i.end > ws && i.start < we], sorted

Особенности: вычисления в локальном времени зоны TZID, затем перевод в UTC (переход на летнее время: 02:30 в несуществующий час → сдвиг вперёд; неоднозначный час → первый экземпляр); для «плавающих» событий — локальная зона пользователя; «весь день» — DATE без зоны; BYDAY=MO,TU с WEEKLY и WKST=SU — неделя начинается с воскресенья; BYMONTHDAY=31 в коротких месяцах пропускается (а не переносится); кэш event_instances пересчитывается при изменении события для окна ±2 года, дальше — лениво; бесконечные серии хранят только окно. Описание по-русски генерируется из RRule шаблонами («каждые 2 недели по вт, чт, до 31.12.2026»).

5.4 Занятость (free/busy) и «найти время»

  1. Источники по участнику (по приоритету): локальный календарь (если это я), кэш freebusy_cache (TTL 15 мин), CalDAV free-busy-query REPORT (RFC 4791 §7.10) к серверу участника (через calendar-home-set того же сервера), Exchange/Graph getSchedule, iTIP VFREEBUSY REQUEST по почте (ответ придёт асинхронно).
  2. Интервалы занятости нормализуются в UTC, сливаются (сортировка по началу, объединение пересекающихся), типы: BUSY, BUSY-TENTATIVE, BUSY-UNAVAILABLE (вне офиса), FREE, UNKNOWN (нет данных).
  3. «Найти время»: вход — участники, длительность D, диапазон, рабочие часы каждого (из профиля или 9–18 в его зоне), шаг 15 мин. Проходим по времени, окно подходит, если у всех обязательных участников нет BUSY/ BUSY-UNAVAILABLE, у ≥ 50 % необязательных — тоже; ранжирование: раньше по дате, затем меньше TENTATIVE, затем ближе к середине рабочего дня; первые 5 вариантов показываются в ML-23.

5.5 Парсинг MIME

Парсер codecs/mail/mime — потоковый, без копирования (&[u8]-срезы), устойчивый к мусору (фаззинг):

  1. Заголовки: развёртывание (unfolding), RFC 2047 encoded-words (=?utf-8?B?…?=, в т.ч. соседние и в произвольных местах — «мягкий» режим), RFC 2231 параметры (filename*0*=, кодировка/язык, продолжения), RFC 6532 UTF-8 в заголовках, адреса по RFC 5322 с исправлением типичных ошибок (неэкранированные кавычки, запятые в именах, < без >), Date с нестандартными формами (без секунд, с названием зоны, двузначный год), Message-ID без скобок.
  2. Структура: multipart/* по границе (boundary, терпимость к отсутствующему закрывающему разделителю), вложенные message/rfc822, multipart/alternative (выбор: HTML, иначе text, иначе первая часть), multipart/related (cid: → части по Content-ID), multipart/signed/encrypted (S/MIME, PGP/MIME), application/ms-tnef (winmail.dat → распаковка TNEF: вложения, RTF-тело → HTML через кодек RTF), text/calendar (METHOD=REQUEST → карточка события), text/vcard.
  3. Декодирование: base64 (терпимо к пробелам/переносам), quoted-printable (мягкие переносы, = в конце), 7bit/8bit/binary; кодировки символов через encoding_rs с псевдонимами (cp1251, koi8-r, windows-1251, gb2312 → gbk и т.п.), при отсутствии charset — определение (chardetng) с приоритетом UTF-8/cp1251.
  4. format=flowed (RFC 3676) для текстовых частей, delsp.
  5. Вложения: Content-Disposition: attachment или inline с именем, или cid без ссылки из HTML → вложение; имя — из filename, иначе name, иначе по типу (attachment.pdf); обеззараживание имён (пути, .., управляющие символы, двойные расширения помечаются).
  6. Результат — дерево MimePart с секциями IMAP (1, 1.1, 2) для догрузки частей по требованию.

5.6 Безопасный рендер HTML

Слой Решение
Санитайзер DOM-парсер (html5ever) → allowlist тегов (блочные/строчные/таблицы/изображения/ссылки, без script, iframe, object, embed, form, input, base, meta refresh, svg с скриптами), allowlist атрибутов, CSS только безопасные свойства (color, background, font-*, margin, padding, border, width/height, text-*, display ограниченно; без position:fixed, url() кроме cid:/data:image), <style> — разбор и перепись в inline по тем же правилам; javascript:/vbscript:/data:text/html — удаляются
Рендер по умолчанию собственный движок пакета: HTML-кодек Write → модель документа → text-layout → Skia. Без JavaScript по построению, без сети: cid: из частей письма, https: изображения — только после «Показать изображения» (через загрузчик с лимитами, без cookies, без Referer, с Accept только image/*)
Полный рендер (по кнопке) Qt WebEngine в отдельном процессе с профилем без JS, без cookies, без localStorage, CSP default-src 'none'; img-src cid: data: (https: при разрешении); style-src 'unsafe-inline', перехват всех навигаций (ссылки открываются во внешнем браузере после подтверждения), запрет плагинов, таймаут рендера
Ссылки показ реального URL во всплывающей подсказке, сравнение с текстом ссылки, предупреждение при несовпадении домена/IDN; mailto: — в составление
Печать/PDF из собственного рендера

Мерило точности: корпус 3 000 реальных писем (рассылки, Outlook-таблицы, Gmail-цитаты) — снимочные тесты; цель 1.0: ≥ 95 % писем визуально без дефектов в собственном рендере, остальные — через полный рендер.

5.7 Спам-фильтр

Локальный наивный байес с χ²-комбинированием (Robinson, как в SpamBayes/Bogofilter):

  1. Токенизация: слова (Unicode, нижний регистр, длина 3–40), токены заголовков с префиксами (from:, subj:, url: домены ссылок, hdr:X-Mailer), биграммы для темы, признаки (attach:exe, html:hidden-text, ratio:img/text), результаты Authentication-Results (dmarc:fail).
  2. Обучение: счётчики (token, ham_count, spam_count) в spam/tokens.db; обучение при «Спам»/«Не спам», при серверных метках (с весом 0,5), при перемещении из Junk; «train on error» — только ошибки и неуверенные.
  3. Оценка: p(t) = (s·x + n·p_raw) / (s + n), s = 0.45, x = 0.5, p_raw = spam_freq / (spam_freq + ham_freq); берём 150 самых «показательных» токенов (|p − 0.5| наибольшая); H = C⁻¹(−2·ln∏(1−p), 2n), S = C⁻¹(−2·ln∏p, 2n), I = (1 + S − H)/2; спам при I > 0.9, «не уверен» при 0.2…0.9.
  4. Объединение с серверной оценкой (X-Spam-Score, Gmail/Outlook категория Junk) — максимум; белый список (контакты, свои домены) — всегда ham; чёрный — всегда спам.
  5. Действие: в папку Junk (серверную при наличии), флаг $Junk, баннер «похоже на спам» при неуверенности.

5.8 Дедупликация контактов

candidates():
  блоки по ключам: email_norm (lowercase, без точек для gmail, без +тега), phone_e164, (family_norm, given_initial), fn_norm
  для каждой пары (a, b) в одном блоке, a < b, (a,b) ∉ not_duplicates:
    score = max over evidence:
      email совпал (точно)                   → 1.00
      телефон E.164 совпал                   → 0.90
      fn совпало (нормализовано, транслит)   → 0.70
      family + given (Jaro-Winkler ≥ 0.92)   → 0.60
      org + family                           → 0.55
      family + bday                          → 0.65
    + бонусы: совпадение второго поля (+0.15 каждое, до 1.0)
    − штрафы: разные bday (−0.5), разные org при одинаковом имени (−0.1)
    если score ≥ 0.85 → «дубликат», 0.6…0.85 → «возможно» (показать), иначе пропустить
merge(a, b):
  поля — объединение множеств (emails, phones, addresses с дедупликацией по нормализации), скаляры — непустое,
  при конфликте — выбор пользователя (ML-24), по умолчанию более свежий modified; UID остаётся у a,
  b удаляется на сервере, группы переносятся, not_duplicates очищается для b

Нормализация имён: NFKC, нижний регистр, удаление диакритики, ё→е, транслит кириллица↔︎латиница при сравнении (Ivanov ≈ Иванов), порядок «Фамилия Имя» и «Имя Фамилия» — оба варианта.

5.9 Очередь отправки

5.10 Полнотекстовый индекс

5.11 Правила

Правило — JSON: {"all": true, "conditions": [{"field":"from","op":"contains","value":"@vektor.ru"}, …], "actions": [{"move":{"folder":42}}, {"label":"Работа"}, {"stop":true}]}. Исполнение: для каждого нового письма в порядке ord, остановка по stop; операторы: contains|equals|starts|ends|regex|in_addressbook|empty| gt|lt (размер/дата); действия, требующие сервера, идут через pending_ops. Перевод в Sieve: подмножество (address, header, size, fileinto, addflag, redirect, discard, stop); несовместимое — предупреждение.

5.12 MeridianScript API

Глобальный объект App (псевдоним Application для переноса кода из Outlook Object Model). События и методы асинхронные. Песочница: отправка писем и доступ к контактам требуют разрешений в манифесте скрипта (mail.send, contacts.read, …); при первом запуске пользователь подтверждает.

interface MailApp {
  accounts: Account[]; defaultAccount: Account;
  folders: { inbox: Folder; sent: Folder; drafts: Folder; trash: Folder; byId(id: number): Folder; find(path: string): Folder | null };
  selection: Item[]; activeItem: Item | null;
  createItem(kind: 'mail'): MailItem; createItem(kind: 'appointment'): AppointmentItem;
  createItem(kind: 'contact'): ContactItem; createItem(kind: 'task'): TaskItem;
  search(query: string, opts?: { account?: Account; folder?: Folder; limit?: number }): Promise<MailItem[]>;
  calendars: Calendar[]; addressBooks: AddressBook[]; taskLists: TaskList[];
  on(event: 'newMail', h: (e: { item: MailItem }) => void): Disposable;
  on(event: 'beforeSend', h: (e: { item: MailItem; cancel: boolean; reason?: string }) => void | Promise<void>): Disposable;
  on(event: 'itemChanged' | 'itemMoved' | 'itemDeleted', h: (e: { item: Item; folder?: Folder }) => void): Disposable;
  on(event: 'reminder', h: (e: { item: AppointmentItem | TaskItem }) => void): Disposable;
  on(event: 'syncCompleted', h: (e: { account: Account }) => void): Disposable;
  ui: { message(text: string): Promise<void>; prompt(label: string): Promise<string | null>; notify(title: string, body: string): void };
}
interface Account { id: number; name: string; email: string; kind: string; folders: Folder[]; identities: Identity[]; sync(): Promise<void>; }
interface Folder { id: number; name: string; path: string; role: string; unread: number; total: number; parent: Folder | null; children: Folder[];
  items(opts?: { unreadOnly?: boolean; since?: Date; limit?: number }): Promise<MailItem[]>; }
interface Item { id: number; kind: 'mail'|'appointment'|'contact'|'task'; created: Date; modified: Date; categories: string[];
  save(): Promise<void>; delete(): Promise<void>; open(): void; }
interface MailItem extends Item {
  subject: string; from: Address; to: Address[]; cc: Address[]; bcc: Address[]; replyTo: Address[];
  date: Date; body: string; htmlBody: string; snippet: string; attachments: Attachment[];
  unread: boolean; flagged: boolean; answered: boolean; importance: 'low'|'normal'|'high'; folder: Folder; thread: MailItem[];
  headers: Record<string, string>; messageId: string; encryption: 'none'|'smime'|'pgp'; signatureValid: boolean | null;
  reply(all?: boolean): MailItem; forward(): MailItem; send(opts?: { at?: Date }): Promise<void>;
  move(to: Folder): Promise<void>; copy(to: Folder): Promise<void>; markRead(v?: boolean): Promise<void>; flag(due?: Date): Promise<void>;
  addAttachment(path: string | Blob, name?: string): Promise<Attachment>; saveAs(path: string, format: 'eml'|'pdf'|'html'): Promise<void>;
}
interface AppointmentItem extends Item { subject: string; start: Date; end: Date; allDay: boolean; location: string; body: string; calendar: Calendar;
  recurrence: RecurrenceRule | null; attendees: Attendee[]; organizer: Address | null; reminders: Reminder[]; busyStatus: 'free'|'tentative'|'busy'|'oof';
  sendInvitations(): Promise<void>; respond(status: 'accepted'|'tentative'|'declined', opts?: { send?: boolean; comment?: string }): Promise<void>;
  occurrences(from: Date, to: Date): Promise<AppointmentItem[]>; }
interface ContactItem extends Item { fullName: string; firstName: string; lastName: string; company: string; jobTitle: string;
  emails: { type: string; address: string }[]; phones: { type: string; number: string }[]; addresses: PostalAddress[]; birthday: Date | null; notes: string; photo: Blob | null; groups: string[]; }
interface TaskItem extends Item { subject: string; body: string; due: Date | null; start: Date | null; status: 'needs-action'|'in-process'|'completed'|'cancelled';
  percent: number; priority: 1|5|9|0; list: TaskList; parent: TaskItem | null; subtasks: TaskItem[]; linkedMail: MailItem | null; complete(): Promise<void>; }

Примеры:

// 1. Автоматическое сохранение счетов из писем в папку и задача на оплату / Save invoices and create a task
App.on('newMail', async ({ item }) => {
  if (!/счёт|invoice/i.test(item.subject)) return;
  for (const a of item.attachments.filter(a => a.name.endsWith('.pdf'))) {
    await a.saveTo(`~/Документы/Счета/${item.date.toISOString().slice(0,7)}/${a.name}`);
  }
  const t = App.createItem('task');
  t.subject = `Оплатить: ${item.subject}`; t.due = new Date(Date.now() + 5 * 864e5); t.linkedMail = item;
  await t.save();
  await item.move(App.folders.find('Входящие/Счета')!);
});

// 2. Проверка перед отправкой: вложение упомянуто, но не приложено / Attachment reminder before send
App.on('beforeSend', e => {
  const mentions = /вложени|прилага|attached|attachment/i.test(e.item.body);
  if (mentions && e.item.attachments.length === 0) { e.cancel = true; e.reason = 'В письме упомянуто вложение, но файлов нет.'; }
});

// 3. Встреча из выделенного письма с участниками из To/Cc / Meeting from the selected message
const m = App.activeItem as MailItem;
const ap = App.createItem('appointment');
ap.subject = `Обсуждение: ${m.subject}`; ap.start = new Date(Date.now() + 864e5); ap.end = new Date(ap.start.getTime() + 36e5);
ap.attendees = [m.from, ...m.to, ...m.cc].map(a => ({ address: a, role: 'required' }));
ap.body = `Контекст: ${m.snippet}\n${m.messageId}`;
await ap.save(); await ap.sendInvitations();

// 4. Отчёт по непрочитанным за неделю в Sheets / Weekly unread report to Sheets
const items = await App.folders.inbox.items({ unreadOnly: true, since: new Date(Date.now() - 7 * 864e5) });
const rows = items.map(i => [i.date, i.from.address, i.subject, i.attachments.length]);
await Meridian.sheets.createWorkbook({ sheets: [{ name: 'Непрочитанные', rows: [['Дата','От','Тема','Вложений'], ...rows] }] });

Соответствие Outlook Object Model → MeridianScript:

Outlook OM (VBA) MeridianScript
Application.CreateItem(olMailItem) App.createItem('mail')
Application.Session.GetDefaultFolder(olFolderInbox) App.folders.inbox
Folder.Items.Restrict("[Unread] = True") folder.items({ unreadOnly: true })
MailItem.Recipients.Add("a@b") item.to.push({ address: 'a@b' })
MailItem.Attachments.Add(path) await item.addAttachment(path)
MailItem.Send await item.send()
Application.ItemSend (событие) App.on('beforeSend', …)
Application.NewMailEx App.on('newMail', …)
AppointmentItem.MeetingStatus = olMeeting + Send ap.sendInvitations()
AppointmentItem.GetRecurrencePattern ap.recurrence
ContactItem.Email1Address contact.emails[0].address
TaskItem.MarkComplete task.complete()
Namespace.Logon не требуется

6. Форматы

6.1 Свои и стандартные

Формат Чтение Запись Особенности
.mmx (архив) да да раздел 4.4; открытие без импорта; письма — оригинальные RFC 5322
.eml (RFC 5322/MIME) да да запись — байт-в-байт оригинал; черновики — собранные нами
.mbox / mboxrd / mboxo / mboxcl2 да да определение варианта по >From экранированию; From разделитель; большие файлы потоково
Maildir да да (экспорт) cur/new/tmp, флаги в имени файла (:2,FS)
.ics (RFC 5545) да да VEVENT/VTODO/VJOURNAL/VFREEBUSY/VTIMEZONE; неизвестные свойства и X- сохраняются (round-trip); X-WR-CALNAME, X-APPLE-*, X-MICROSOFT-CDO-* интерпретируются
.vcf (vCard 2.1/3.0/4.0) да да (3.0/4.0) 2.1 с QP-кодировкой и CHARSET; фото base64/URI; X- поля в extra; группы Apple (X-ABLabel)
LDIF да да контакты и группы (objectClass: groupOfNames)
CSV контактов да да схемы Outlook (рус/англ), Google, Thunderbird, Яндекс; авто-сопоставление по заголовкам
.msg (MS-OXMSG, CFB) да P2 свойства MAPI → MIME; RTF-тело (PR_RTF_COMPRESSED → распаковка → HTML через кодек RTF); вложения, встроенные .msg
TNEF (winmail.dat) да — RTF, вложения, свойства встречи
PDF — да письмо/цепочка/календарь/контакты через Meridian Print

6.2 Протоколы

IMAP4rev1/rev2 (RFC 3501/9051) с расширениями из 2.1; SMTP (RFC 5321, 6409, 8314 TLS); POP3 (RFC 1939, 2595); CalDAV (RFC 4791, 6638, 5397 current-user-principal, 6764 bootstrapping через .well-known/caldav и DNS SRV); CardDAV (RFC 6352, 6764); WebDAV sync (RFC 6578 sync-collection); iTIP/iMIP; Autocrypt 1.1; EWS 2013 SP1+ (SOAP); Microsoft Graph v1.0; Gmail API v1, Google Calendar v3, People v1, Tasks v1; ManageSieve (RFC 5804, P2); JMAP (P2); LDAP v3 (P1); OAuth 2.0 (RFC 6749, 7636 PKCE, 8252 native apps).

6.3 PST/OST — собственный парсер (codecs/mail/pst)

Основа — спецификация MS-PST. Слои:

Слой Что разбираем
NDB (Node Database) заголовок (magic !BDN, версия: 14/15 — ANSI с 32-битными указателями, 23 — Unicode 64-бит; wVer, bCryptMethod: 0 none, 1 permutative (NDB_CRYPT_PERMUTE), 2 cyclic (NDB_CRYPT_CYCLIC), 0x10 WIP (OST, не поддерживается)); корень (ROOT: BBT/NBT корни, размер файла); деревья страниц BTree (NBT — узлы nid → bidData, bidSub, nidParent; BBT — блоки bid → offset, size, refcount); блоки данных с trailer (cb, wSig, dwCRC, bid), XBLOCK/XXBLOCK для больших данных, SLBLOCK/SIBLOCK для подузлов; страницы 512 байт, блоки кратны 64, выравнивание; AMap/PMap — пропускаем (только чтение)
LTP (Lists, Tables, Properties) Heap-on-Node (HN: HNHDR, страницы HNPAGEMAP, HID), BTree-on-Heap (BTH), Property Context (PC: wPropId → wPropType → value/HID/NID), Table Context (TC: TCINFO, столбцы TCOLDESC, строки в Row Matrix, Cell Existence Bitmap)
Messaging корневая папка (NID_ROOT_FOLDER 0x122), папки (PC: PidTagDisplayName, PidTagContentCount; TC содержимого nid+0xD, иерархии nid+0xE, ассоциированного nid+0xF), сообщения (PC с PidTagSubject, PidTagSenderName, PidTagBody, PidTagHtml, PidTagRtfCompressed, PidTagMessageDeliveryTime, PidTagMessageFlags, PidTagTransportMessageHeaders), получатели (Recipient table), вложения (Attachment table + подузел с PidTagAttachDataBinary/встроенное сообщение PidTagAttachMethod = 5), именованные свойства (NID_NAME_TO_ID_MAP 0x61: GUID + имя/ID → PropId ≥ 0x8000) для календаря/контактов/задач (PSETID_Appointment, PSETID_Address, PSETID_Task)
Преобразование сообщение → MIME: если есть PidTagTransportMessageHeaders — заголовки оригинала; тело: HTML (из PidTagHtml или распакованный RTF с «encapsulated HTML») + text; вложения → части; кодировка по PidTagInternetCodepage/PidTagMessageCodepage; встречи → VEVENT (из PidLidAppointmentStartWhole, PidLidAppointmentRecur — бинарное правило повторения MS-OXOCAL → RRULE), контакты → vCard, задачи → VTODO, заметки → письма в папку «Заметки»
OST тот же NDB/LTP, но bCryptMethod 0x10 и формат блоков 2013+ (wVer 36) отличается — поддержка ограничена Outlook 2013–2016 OST без шифрования (R*)

Проверка — корпус PST (Outlook 2003/2007/2010/2016/365, ANSI и Unicode, с паролем — пароль игнорируется, т.к. это только CRC в PidTagPstPassword), сверка с libpff (pffexport) по числу писем/вложений и содержимому.

6.4 Ограничения чтения (R*)

OST (см. выше), .one-подобные закрытые форматы не поддерживаются; Exchange .edb — нет; письма Lotus Notes .nsf — нет (через экспорт в mbox); .olm (Outlook for Mac) — P2 (ZIP с XML).


7. Горячие клавиши

Схема по умолчанию — Outlook; альтернативные схемы: Thunderbird, Gmail-стиль (j/k/e/#/r/a/f/c).

Действие Клавиши
Модули: Почта / Календарь / Контакты / Задачи Ctrl+1 / Ctrl+2 / Ctrl+3 / Ctrl+4
Новое письмо / встреча / контакт / задача (из любого модуля) Ctrl+Shift+M / Ctrl+Shift+A / Ctrl+Shift+C / Ctrl+Shift+K
Новый элемент текущего модуля Ctrl+N
Ответить / всем / переслать Ctrl+R / Ctrl+Shift+R / Ctrl+F
Отправить Ctrl+Enter или Alt+S
Отправить позже / сохранить черновик Ctrl+Shift+Enter / Ctrl+S
Отправить и получить всё / текущую папку F9 / Shift+F9
Поиск / расширенный поиск / командная палитра Ctrl+E / Ctrl+Shift+F / Ctrl+K
Прочитано / непрочитано Ctrl+Q / Ctrl+U
Флаг / снять флаг / выполнено Ctrl+Shift+G / Ctrl+Shift+G повторно / Insert
Удалить / удалить навсегда / архив Delete / Shift+Delete / Backspace
Переместить в папку / копировать Ctrl+Shift+V / Ctrl+Shift+Y
Спам / не спам Ctrl+J / Ctrl+Alt+J
Категории Ctrl+Shift+1…9
Следующее / предыдущее письмо; непрочитанное ↓ / ↑; Ctrl+. / Ctrl+,
Открыть письмо в окне / закрыть Enter / Esc
Развернуть/свернуть цепочку / все → / ←; Ctrl+Shift+-/+
Показать изображения Ctrl+Shift+I
Показать оригинал / как текст Ctrl+U в окне письма / Ctrl+Shift+T
Печать Ctrl+P
Вложить файл Ctrl+Shift+F в составлении
Проверка орфографии / подпись / шаблон F7 / Ctrl+Shift+S / Ctrl+Shift+Q
Календарь: День / Рабочая неделя / Неделя / Месяц / Повестка / Год Ctrl+Alt+1 / Ctrl+Alt+2 / Ctrl+Alt+3 / Ctrl+Alt+4 / Ctrl+Alt+5 / Ctrl+Alt+6
Перейти к дате / сегодня Ctrl+G / Alt+Home (Outlook) или T (Gmail-схема)
Следующий/предыдущий период Alt+→ / Alt+←
Принять / под вопросом / отклонить приглашение Alt+C / Alt+N / Alt+D
Контакты: написать / встреча / позвонить Ctrl+Shift+M / Ctrl+Shift+A / Ctrl+Shift+D
Задачи: выполнено / сегодня / +1 день Ctrl+D / Ctrl+T / Ctrl+Shift+T
Работать автономно Ctrl+Shift+O
Обновить папку / список F5
Выделить все / инвертировать Ctrl+A / Ctrl+Shift+A в списке

8. Критерии готовности

8.1 Mail 1.0 (P0 закрыты)

  1. Мастер учётной записи настраивает без ручного ввода ≥ 95 % адресов из тестового набора 200 доменов (провайдеры РФ/мира, корпоративные с autoconfig/SRV); OAuth2-вход у Gmail, Microsoft, Яндекс, Mail.ru.
  2. Синхронизация: ящик 200 000 писем (10 ГБ) — первичная загрузка заголовков ≤ 10 мин, тел — в фоне; инкрементальная синхронизация изменений ≤ 2 с при QRESYNC, ≤ 10 с при plain на папке 50 000 писем; IDLE доставляет письмо ≤ 5 с.
  3. Корректность: тестовый IMAP-набор (Dovecot, Cyrus, Gmail, Exchange IMAP, Яндекс, Mail.ru, Courier) — флаги/перемещения/удаления сходятся с сервером во всех 120 сценариях (включая UIDVALIDITY-сброс и офлайн-очередь из 500 операций).
  4. MIME: корпус 10 000 писем (включая намеренно повреждённые) — без паники, 100 % вложений извлечены с правильными именами; фаззинг парсера 24 ч без падений.
  5. Рендер HTML: ≥ 95 % из 3 000 писем корпуса без визуальных дефектов в собственном рендере; ни одна из 200 вредоносных проб (XSS, трекинг, CSS-утечки) не выполняет код/запрос.
  6. Threading: совпадение с эталоном JWZ (jwz.org набор + 1 000 реальных цепочек) ≥ 99 %.
  7. Поиск: запрос по 200 000 писем ≤ 200 мс; индексация нового письма ≤ 3 с.
  8. Спам: на корпусе 10 000 (SpamAssassin public corpus + русские рассылки) после обучения на 500 — ложноположительных ≤ 0,5 %, пропуск спама ≤ 5 %.
  9. Календарь: тест-набор 2 000 RRULE (dateutil, Apple, Google экспорт) — 100 % совпадение экземпляров; CalDAV с Nextcloud, Radicale, iCloud, Google (CalDAV), Fastmail, Yandex — создание/изменение/удаление, повторения с исключениями, приглашения iMIP в обе стороны с Outlook и Google Calendar.
  10. Контакты: CardDAV (те же серверы), vCard 2.1/3.0/4.0 round-trip на 500 карточках; дедупликация на наборе 1 000 с 200 известными дублями — precision ≥ 0.95, recall ≥ 0.9.
  11. Импорт PST: 30 файлов (ANSI/Unicode, 100 МБ–5 ГБ) — число писем/вложений совпадает с libpff, письма открываются; mbox Thunderbird 2 ГБ — ≤ 3 мин.
  12. S/MIME подпись/шифрование совместимы с Outlook и Thunderbird; OpenPGP — с Thunderbird/GnuPG, Autocrypt с Delta Chat/K-9 (P1).
  13. Автономность: 7 дней без сети — чтение, составление, очередь из 100 писем отправляется при подключении в правильном порядке.
  14. Экспорт/импорт .mmx round-trip без потерь (байт-в-байт для писем).
  15. MeridianScript: 4 примера из 5.12 работают; beforeSend может отменить отправку.
  16. Снимочные UI-тесты всех экранов ML-01…ML-28 на трёх ОС.

8.2 Mail 1.x (P1)

Exchange EWS и Graph (почта, календарь, контакты, задачи, free/busy); Gmail API/Google Calendar/People/Tasks; серверные правила Exchange; большие вложения через Meridian Cloud; слияние-рассылка; общие календари; LDAP-книги; OST (R*); автоархивация.


9. Риски

Риск Вероятность Влияние Снижение
Регистрация OAuth-приложений у провайдеров: Google требует верификации приложения для scope Gmail (аудит безопасности CASA, платный, ежегодно); Microsoft — регистрация в Entra ID, согласие администраторов в организациях; Яндекс/Mail.ru — модерация; возможны отзывы высокая без своих client_id пользователи не смогут войти по OAuth в Gmail/Outlook.com начать регистрацию в фазе 4; пройти CASA Tier 2; запасной путь — пароли приложений (Gmail/Яндекс/Mail.ru), ввод своего client_id (корпорации); IMAP вместо Gmail API, чтобы минимизировать scopes (https://mail.google.com/ всё равно restricted)
Exchange: EWS объявлен устаревшим для Microsoft 365 (отключение для новых приложений с 2026), Graph не покрывает on-prem высокая часть корпоративных сценариев не работает EWS только для on-prem, Graph для 365; IMAP/SMTP как минимальный путь; Autodiscover v2
Разнообразие IMAP-серверов и их отклонений от RFC (Exchange IMAP, Mail.ru, старые Courier) высокая ошибки синхронизации, потери матрица тестовых серверов в CI (Docker: Dovecot, Cyrus, Stalwart; живые: Gmail, Яндекс, Mail.ru, Exchange), «карантин» capabilities по серверу (известные баги → отключение расширений), подробные журналы протокола
Собственный парсер PST — объём работы и редкие структуры средняя неполный импорт спецификация MS-PST + корпус; на старте — проверка через libpff в тестах; при провале — FFI к libpff как запасной путь (LGPL, динамическая линковка)
Безопасный рендер собственным движком отстаёт от «браузерной» точности писем-рассылок средняя жалобы на вид писем корпус 3 000 писем, снимки, кнопка «полный рендер» в изолированном WebEngine
Часовые пояса: Windows-зоны ↔︎ IANA, устаревшая tzdb в системе, переход РФ-зон средняя неверное время встреч встроенная tzdb с обновлениями через Meridian Update, CLDR windowsZones, тесты DST
Спам-фильтр локально хуже серверного Gmail низкая лишний спам объединение с серверными метками, фильтр — дополнение
OpenPGP UX (ключи, Autocrypt) сложен средняя мало используется Autocrypt по умолчанию, скрытие сложностей, мастер
Производительность списка на 1 М писем в одной папке (Gmail «Все письма») средняя тормоза виртуализация, курсоры SQLite с keyset-пагинацией, индексы, ленивая загрузка snippet
Хранение писем в профиле — рост до десятков ГБ средняя заполнение диска политика «тела за N дней», очистка, индикатор размера, zstd
Лицензионная граница «Mail-календарь не в Free» требует разбиения модулей низкая путаница Feature::Mail целиком Home+, Free не содержит Mail — уточнить в библии (вопрос 10.1)

10. Вопросы к библии

  1. Раздел 9.1, Free: указано «без … Mail-календаря», но Mail целиком входит только в Home. Предлагаем формулировку «без Mail» для Free (Mail = почта + календарь + контакты + задачи, не делится по редакциям).
  2. Exchange и Graph в редакциях: предлагаем Feature::MailExchange (EWS/Graph) — Business+, как в таблице 9.1 нет явного указания; IMAP/CalDAV/CardDAV — во всех редакциях с Mail.
  3. Регистрация OAuth-клиентов (Google CASA, Microsoft Entra, Яндекс, Mail.ru) — организационный пункт фазы 4, нужен владелец и бюджет; стоит отразить в 08-roadmap.md.
  4. Таблица 3.3: для Mail предлагаем добавить .msg в «Полная поддержка» после реализации записи (P2) и Maildir, .olm в чтение.