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
@@ -0,0 +1,255 @@
package com.mailserver.imap;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.ContentDisposition;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeUtility;
import jakarta.mail.internet.ParameterList;
import jakarta.mail.internet.ParseException;
import java.io.ByteArrayOutputStream;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.Locale;
/** Builds IMAP response bytes: strings, literals, ENVELOPE, BODYSTRUCTURE (RFC 3501 7.4.2). */
final class ImapWriter {
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
ImapWriter text(String text) {
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
out.write(bytes, 0, bytes.length);
return this;
}
ImapWriter sp() {
out.write(' ');
return this;
}
ImapWriter crlf() {
return text("\r\n");
}
ImapWriter literal(byte[] bytes) {
text("{" + bytes.length + "}\r\n");
out.write(bytes, 0, bytes.length);
return this;
}
/** Quoted when it is plain ASCII without CR/LF, a literal otherwise; null → NIL. */
ImapWriter nstring(String value) {
if (value == null) {
return text("NIL");
}
return string(value);
}
ImapWriter string(String value) {
boolean quotable = value.length() < 1024;
for (int i = 0; i < value.length() && quotable; i++) {
char c = value.charAt(i);
if (c > 0x7e || c < 0x20) {
quotable = false;
}
}
if (!quotable) {
return literal(value.getBytes(StandardCharsets.UTF_8));
}
return text("\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"");
}
byte[] bytes() {
return out.toByteArray();
}
// ---------- ENVELOPE ----------
ImapWriter envelope(MimeTree message) {
text("(");
nstring(message.header("Date")).sp();
nstring(message.header("Subject")).sp();
String from = message.header("From");
addresses(from).sp();
addresses(firstNonNull(message.header("Sender"), from)).sp();
addresses(firstNonNull(message.header("Reply-To"), from)).sp();
addresses(message.header("To")).sp();
addresses(message.header("Cc")).sp();
addresses(message.header("Bcc")).sp();
nstring(message.header("In-Reply-To")).sp();
nstring(message.header("Message-ID"));
return text(")");
}
private ImapWriter addresses(String header) {
if (header == null || header.isBlank()) {
return text("NIL");
}
InternetAddress[] list;
try {
list = InternetAddress.parseHeader(header, false);
} catch (AddressException e) {
return text("NIL");
}
if (list.length == 0) {
return text("NIL");
}
text("(");
for (InternetAddress address : list) {
String email = address.getAddress() == null ? "" : address.getAddress();
int at = email.lastIndexOf('@');
text("(");
nstring(encodedPersonal(address.getPersonal())).sp().text("NIL").sp();
nstring(at > 0 ? email.substring(0, at) : email).sp();
nstring(at > 0 ? email.substring(at + 1) : null);
text(")");
}
return text(")");
}
private static String encodedPersonal(String personal) {
if (personal == null) {
return null;
}
try {
return MimeUtility.encodeWord(personal, "UTF-8", "B");
} catch (UnsupportedEncodingException e) {
return personal;
}
}
// ---------- BODYSTRUCTURE / BODY ----------
ImapWriter bodyStructure(MimeTree part, boolean extensible) {
text("(");
if (part.isMultipart() && !part.children.isEmpty()) {
for (MimeTree child : part.children) {
bodyStructure(child, extensible);
}
sp().string(part.type.getSubType().toUpperCase(Locale.ROOT));
if (extensible) {
sp().params(part.type.getParameterList()).sp();
disposition(part).sp().text("NIL NIL");
}
return text(")");
}
String primary = part.isMultipart() ? "TEXT" : part.type.getPrimaryType().toUpperCase(Locale.ROOT);
String sub = part.isMultipart() ? "PLAIN" : part.type.getSubType().toUpperCase(Locale.ROOT);
string(primary).sp().string(sub).sp();
params(part.type.getParameterList()).sp();
nstring(part.header("Content-ID")).sp();
nstring(part.header("Content-Description")).sp();
String encoding = part.header("Content-Transfer-Encoding");
string(encoding == null ? "7BIT" : encoding.toUpperCase(Locale.ROOT)).sp();
text(String.valueOf(part.bodySize()));
if ("TEXT".equals(primary)) {
sp().text(String.valueOf(part.bodyLines()));
} else if (part.message != null) {
sp().envelope(part.message).sp().bodyStructure(part.message, extensible).sp().text(String.valueOf(part.bodyLines()));
}
if (extensible) {
sp().nstring(part.header("Content-MD5")).sp();
disposition(part).sp();
nstring(part.header("Content-Language")).sp();
nstring(part.header("Content-Location"));
}
return text(")");
}
private ImapWriter params(ParameterList params) {
if (params == null || params.size() == 0) {
return text("NIL");
}
text("(");
boolean first = true;
Enumeration<String> names = params.getNames();
while (names.hasMoreElements()) {
String name = names.nextElement();
if (!first) {
sp();
}
first = false;
string(name.toUpperCase(Locale.ROOT)).sp().string(String.valueOf(params.get(name)));
}
return text(")");
}
private ImapWriter disposition(MimeTree part) {
String header = part.header("Content-Disposition");
if (header == null) {
return text("NIL");
}
try {
ContentDisposition disposition = new ContentDisposition(header);
text("(").string(disposition.getDisposition().toUpperCase(Locale.ROOT)).sp();
params(disposition.getParameterList());
return text(")");
} catch (ParseException e) {
return text("NIL");
}
}
private static String firstNonNull(String a, String b) {
return a != null ? a : b;
}
// ---------- modified UTF-7 (RFC 3501 5.1.3) for mailbox names ----------
static String encodeMailbox(String name) {
StringBuilder out = new StringBuilder();
StringBuilder pending = new StringBuilder();
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
if (c >= 0x20 && c <= 0x7e) {
flushBase64(out, pending);
out.append(c == '&' ? "&-" : String.valueOf(c));
} else {
pending.append(c);
}
}
flushBase64(out, pending);
return out.toString();
}
private static void flushBase64(StringBuilder out, StringBuilder pending) {
if (pending.length() == 0) {
return;
}
byte[] utf16 = pending.toString().getBytes(StandardCharsets.UTF_16BE);
String base64 = java.util.Base64.getEncoder().withoutPadding().encodeToString(utf16).replace('/', ',');
out.append('&').append(base64).append('-');
pending.setLength(0);
}
static String decodeMailbox(String name) {
StringBuilder out = new StringBuilder();
int i = 0;
while (i < name.length()) {
char c = name.charAt(i);
if (c != '&') {
out.append(c);
i++;
continue;
}
int dash = name.indexOf('-', i);
if (dash < 0) {
out.append(name.substring(i));
break;
}
if (dash == i + 1) {
out.append('&');
} else {
try {
byte[] utf16 = java.util.Base64.getDecoder().decode(name.substring(i + 1, dash).replace(',', '/'));
out.append(new String(utf16, StandardCharsets.UTF_16BE));
} catch (IllegalArgumentException e) {
out.append(name, i, dash + 1);
}
}
i = dash + 1;
}
return out.toString();
}
}