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:
@@ -0,0 +1,250 @@
|
||||
package com.mailserver.imap;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.ContentType;
|
||||
import jakarta.mail.internet.InternetHeaders;
|
||||
import jakarta.mail.internet.ParseException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A message split into MIME parts by byte offsets — what IMAP needs for BODYSTRUCTURE and BODY[section]:
|
||||
* every part is a range of the original bytes, so sizes and contents match BODY[] exactly.
|
||||
*/
|
||||
final class MimeTree {
|
||||
|
||||
private static final int MAX_DEPTH = 20;
|
||||
|
||||
final byte[] raw;
|
||||
/** Part = [start, end); headers = [start, bodyStart), the blank line included. */
|
||||
final int start;
|
||||
final int bodyStart;
|
||||
final int end;
|
||||
final InternetHeaders headers;
|
||||
final ContentType type;
|
||||
final List<MimeTree> children;
|
||||
/** The encapsulated message of a message/rfc822 part. */
|
||||
final MimeTree message;
|
||||
|
||||
private MimeTree(byte[] raw, int start, int end, int depth) {
|
||||
this.raw = raw;
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
this.bodyStart = headerEnd(raw, start, end);
|
||||
this.headers = parseHeaders(raw, start, bodyStart);
|
||||
this.type = contentType(headers);
|
||||
List<MimeTree> parts = new ArrayList<>();
|
||||
MimeTree nested = null;
|
||||
if (depth < MAX_DEPTH) {
|
||||
if (isMultipart()) {
|
||||
String boundary = type.getParameter("boundary");
|
||||
if (boundary != null && !boundary.isEmpty()) {
|
||||
split(boundary, depth, parts);
|
||||
}
|
||||
} else if (is("message", "rfc822") || is("message", "global")) {
|
||||
nested = new MimeTree(raw, bodyStart, end, depth + 1);
|
||||
}
|
||||
}
|
||||
this.children = Collections.unmodifiableList(parts);
|
||||
this.message = nested;
|
||||
}
|
||||
|
||||
static MimeTree parse(byte[] raw) {
|
||||
return new MimeTree(raw, 0, raw.length, 0);
|
||||
}
|
||||
|
||||
boolean isMultipart() {
|
||||
return "multipart".equalsIgnoreCase(type.getPrimaryType());
|
||||
}
|
||||
|
||||
boolean is(String primary, String sub) {
|
||||
return primary.equalsIgnoreCase(type.getPrimaryType()) && sub.equalsIgnoreCase(type.getSubType());
|
||||
}
|
||||
|
||||
byte[] all() {
|
||||
return slice(start, end);
|
||||
}
|
||||
|
||||
byte[] header() {
|
||||
return slice(start, bodyStart);
|
||||
}
|
||||
|
||||
byte[] body() {
|
||||
return slice(bodyStart, end);
|
||||
}
|
||||
|
||||
int bodySize() {
|
||||
return end - bodyStart;
|
||||
}
|
||||
|
||||
/** Lines of the body (for text parts in BODYSTRUCTURE). */
|
||||
int bodyLines() {
|
||||
int lines = 0;
|
||||
for (int i = bodyStart; i < end; i++) {
|
||||
if (raw[i] == '\n') {
|
||||
lines++;
|
||||
}
|
||||
}
|
||||
if (end > bodyStart && raw[end - 1] != '\n') {
|
||||
lines++;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Header value unfolded, or null. */
|
||||
String header(String name) {
|
||||
String[] values = headers.getHeader(name);
|
||||
if (values == null || values.length == 0 || values[0] == null) {
|
||||
return null;
|
||||
}
|
||||
return values[0].replaceAll("\r?\n(?=[ \t])", "").trim();
|
||||
}
|
||||
|
||||
/** Only these header fields (or all but these), each with its folded lines, plus the blank line. */
|
||||
byte[] headerFields(Set<String> names, boolean not) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
String text = new String(raw, start, bodyStart - start, StandardCharsets.ISO_8859_1);
|
||||
String[] lines = text.split("\r?\n", -1);
|
||||
boolean keep = false;
|
||||
for (String line : lines) {
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (line.charAt(0) == ' ' || line.charAt(0) == '\t') {
|
||||
if (keep) {
|
||||
out.append(line).append("\r\n");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
int colon = line.indexOf(':');
|
||||
String name = colon > 0 ? line.substring(0, colon).trim().toUpperCase(Locale.ROOT) : "";
|
||||
keep = names.contains(name) != not;
|
||||
if (keep) {
|
||||
out.append(line).append("\r\n");
|
||||
}
|
||||
}
|
||||
out.append("\r\n");
|
||||
return out.toString().getBytes(StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
|
||||
private byte[] slice(int from, int to) {
|
||||
byte[] copy = new byte[Math.max(0, to - from)];
|
||||
System.arraycopy(raw, from, copy, 0, copy.length);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Body parts between "--boundary" lines; the CRLF before a delimiter belongs to the delimiter. */
|
||||
private void split(String boundary, int depth, List<MimeTree> parts) {
|
||||
byte[] delimiter = ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1);
|
||||
int partStart = -1;
|
||||
int i = bodyStart;
|
||||
while (i < end) {
|
||||
int lineEnd = indexOf(raw, (byte) '\n', i, end);
|
||||
int next = lineEnd < 0 ? end : lineEnd + 1;
|
||||
if (startsWith(raw, i, end, delimiter)) {
|
||||
int after = i + delimiter.length;
|
||||
boolean close = after + 1 < end && raw[after] == '-' && raw[after + 1] == '-';
|
||||
if (partStart >= 0) {
|
||||
int partEnd = i;
|
||||
if (partEnd > partStart && raw[partEnd - 1] == '\n') {
|
||||
partEnd--;
|
||||
if (partEnd > partStart && raw[partEnd - 1] == '\r') {
|
||||
partEnd--;
|
||||
}
|
||||
}
|
||||
parts.add(new MimeTree(raw, partStart, Math.max(partStart, partEnd), depth + 1));
|
||||
}
|
||||
if (close) {
|
||||
return;
|
||||
}
|
||||
partStart = next;
|
||||
}
|
||||
i = next;
|
||||
}
|
||||
if (partStart >= 0 && partStart < end) { // no closing delimiter — take the rest
|
||||
parts.add(new MimeTree(raw, partStart, end, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
private static int headerEnd(byte[] raw, int start, int end) {
|
||||
if (start < end && raw[start] == '\n') {
|
||||
return start + 1;
|
||||
}
|
||||
if (start + 1 < end && raw[start] == '\r' && raw[start + 1] == '\n') {
|
||||
return start + 2;
|
||||
}
|
||||
for (int i = start; i < end; i++) {
|
||||
if (raw[i] == '\n') {
|
||||
if (i + 1 < end && raw[i + 1] == '\n') {
|
||||
return i + 2;
|
||||
}
|
||||
if (i + 2 < end && raw[i + 1] == '\r' && raw[i + 2] == '\n') {
|
||||
return i + 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
return end; // headers only
|
||||
}
|
||||
|
||||
private static InternetHeaders parseHeaders(byte[] raw, int start, int end) {
|
||||
try {
|
||||
return new InternetHeaders(new ByteArrayInputStream(raw, start, end - start));
|
||||
} catch (MessagingException e) {
|
||||
return new InternetHeaders();
|
||||
}
|
||||
}
|
||||
|
||||
private static ContentType contentType(InternetHeaders headers) {
|
||||
String[] values = headers.getHeader("Content-Type");
|
||||
if (values != null && values.length > 0 && values[0] != null) {
|
||||
try {
|
||||
return new ContentType(values[0].replaceAll("\r?\n(?=[ \t])", "").trim());
|
||||
} catch (ParseException ignored) {
|
||||
// broken header — the RFC 2045 default
|
||||
}
|
||||
}
|
||||
try {
|
||||
return new ContentType("text/plain; charset=us-ascii");
|
||||
} catch (ParseException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean startsWith(byte[] raw, int at, int end, byte[] prefix) {
|
||||
if (at + prefix.length > end) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < prefix.length; i++) {
|
||||
if (raw[at + i] != prefix[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int indexOf(byte[] raw, byte value, int from, int end) {
|
||||
for (int i = from; i < end; i++) {
|
||||
if (raw[i] == value) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Header names of the part, for tests and debugging. */
|
||||
List<String> headerNames() {
|
||||
List<String> names = new ArrayList<>();
|
||||
Enumeration<jakarta.mail.Header> all = headers.getAllHeaders();
|
||||
while (all.hasMoreElements()) {
|
||||
names.add(all.nextElement().getName());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user