75.2 IMAP: новый сервер на Reactor Netty (STARTTLS, 993, IDLE, UIDPLUS, MOVE)

Старый IMAP (выключен, .block() на сетевом потоке) удалён, написан заново:
- ImapServer: декодер целых команд с литералами ({n} после "+ Ready", {n+}),
  команды по одной на рабочем потоке; STARTTLS (без выполнения открытого текста
  после него) и неявный TLS; IDLE с опросом раз в 5 с до DONE
- ImapSession: IMAP4rev1 + LITERAL+, SASL-IR, IDLE, UIDPLUS, MOVE, UNSELECT,
  NAMESPACE, SPECIAL-USE, ID; FETCH (ENVELOPE, BODYSTRUCTURE, BODY[section]<partial>),
  SEARCH, STORE, COPY/MOVE с COPYUID, APPEND с APPENDUID, EXPUNGE/UID EXPUNGE
- MimeTree: части письма по байтам — размеры BODYSTRUCTURE совпадают с BODY[]
- V4: UID на папку выдаёт триггер (перенос из веба тоже получает новый UID),
  флаги \Answered/\Deleted, message_sources — письмо как пришло (SMTP, APPEND)
  или собранное один раз (MimeSource); правка черновика сбрасывает его
- тесты: настоящий клиент Angus IMAP, сырые литералы/IDLE/подмешивание (47 всего)
- живая проверка: обновление V3 -> V4 на PostgreSQL с письмами, Python imaplib:
  STARTTLS, 993, папки, вложения, поиск, APPEND/COPY/MOVE/EXPUNGE, IDLE — 0 ошибок

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 20:45:06 +01:00
parent 73c03b1d6d
commit b72be91a8b
20 changed files with 3408 additions and 599 deletions
@@ -57,10 +57,12 @@ mailserver.outbound.username=${OUTBOUND_USERNAME:}
mailserver.outbound.password=${OUTBOUND_PASSWORD:}
mailserver.outbound.tls=${OUTBOUND_TLS:starttls}
# IMAP — off: simplified, blocks the event loop (v0.2.3 75.2)
mailserver.imap.enabled=false
mailserver.imap.port=1143
mailserver.imap.hostname=localhost
# IMAP (75.2): STARTTLS on IMAP_PORT, implicit TLS on IMAPS_PORT (993) if set; same certificate as SMTP.
# LOGIN only inside TLS unless IMAP_AUTH_WITHOUT_TLS=true (a closed network only)
mailserver.imap.enabled=${IMAP_ENABLED:true}
mailserver.imap.port=${IMAP_PORT:3143}
mailserver.imaps.port=${IMAPS_PORT:0}
mailserver.imap.auth-without-tls=${IMAP_AUTH_WITHOUT_TLS:false}
# DKIM — signing of our mail; DNS record printed to the log at start
mailserver.dkim.enabled=${DKIM_ENABLED:true}
@@ -0,0 +1,43 @@
-- IMAP (75.2): UIDs per folder, IMAP flags, the original message text
-- UIDVALIDITY: never changes for a folder; UIDNEXT: the next UID to give out
ALTER TABLE folders ADD COLUMN uid_validity BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM now())::BIGINT);
ALTER TABLE folders ADD COLUMN uid_next BIGINT NOT NULL DEFAULT 1;
-- UID inside the folder; strictly increasing — a message moved into a folder gets a new, larger UID
ALTER TABLE messages ADD COLUMN uid BIGINT;
ALTER TABLE messages ADD COLUMN is_answered BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE messages ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE messages m SET uid = numbered.n
FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY folder_id ORDER BY received_at, id) AS n FROM messages) numbered
WHERE m.id = numbered.id;
UPDATE folders f SET uid_next = COALESCE((SELECT MAX(uid) FROM messages WHERE folder_id = f.id), 0) + 1;
ALTER TABLE messages ALTER COLUMN uid SET NOT NULL;
CREATE UNIQUE INDEX idx_messages_folder_uid ON messages(folder_id, uid);
-- The UID is given out by the database, so every path that stores or moves a message (SMTP, web client, IMAP)
-- gets it right without knowing about IMAP
CREATE FUNCTION assign_message_uid() RETURNS trigger AS $$
BEGIN
UPDATE folders SET uid_next = uid_next + 1 WHERE id = NEW.folder_id RETURNING uid_next - 1 INTO NEW.uid;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER messages_uid_on_insert BEFORE INSERT ON messages
FOR EACH ROW EXECUTE FUNCTION assign_message_uid();
-- The web client saves the whole row (folder_id included) — only a real move gets a new UID
CREATE TRIGGER messages_uid_on_move BEFORE UPDATE OF folder_id ON messages
FOR EACH ROW WHEN (NEW.folder_id IS DISTINCT FROM OLD.folder_id) EXECUTE FUNCTION assign_message_uid();
-- The message exactly as received (SMTP) or appended (IMAP APPEND); for messages without it the server builds
-- the MIME text once from the stored fields and keeps it here, so sizes and bytes never change between fetches.
-- Removed when a draft changes.
CREATE TABLE message_sources (
message_id BIGINT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
raw BYTEA NOT NULL
);