package com.mailserver.imap; import com.mailserver.security.LoginGuard; import com.mailserver.service.LocalDelivery; import jakarta.mail.Session; import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeUtility; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Base64; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.TreeSet; import java.util.function.Predicate; import java.util.regex.Pattern; /** * One IMAP connection (RFC 3501 + LITERAL+, IDLE, UIDPLUS, MOVE, UNSELECT, NAMESPACE, ID, SPECIAL-USE). * Commands run one at a time on a worker thread; each returns the response bytes to write. */ final class ImapSession { private static final Logger log = LoggerFactory.getLogger(ImapSession.class); private static final int MAX_BAD_COMMANDS = 30; /** Wrong passwords in one connection before it is closed. */ private static final int MAX_AUTH_FAILURES = 3; private static final int SOURCE_CACHE = 16; private static final DateTimeFormatter INTERNAL_DATE = DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm:ss Z", Locale.ENGLISH); private static final DateTimeFormatter SEARCH_DATE = DateTimeFormatter.ofPattern("d-MMM-yyyy", Locale.ENGLISH); private static final String PERMANENT_FLAGS = "(\\Answered \\Flagged \\Deleted \\Seen)"; /** Compared by identity: TLS starts right after this response is written. */ static final byte[] STARTTLS_READY = "starttls".getBytes(StandardCharsets.US_ASCII); record Settings(String hostname, boolean tlsAvailable, boolean authWithoutTls) { } private final Settings settings; private final ImapStore store; private final LoginGuard guard; final String remoteIp; boolean tls; boolean closing; private ImapStore.Account account; private String pendingAuthTag; private int badCommands; // Selected mailbox: messages in sequence order (index + 1 = sequence number) private ImapStore.Mailbox selected; private boolean readOnly; private final List view = new ArrayList<>(); private final Set goneUids = new HashSet<>(); private final LinkedHashMap sourceCache = new LinkedHashMap<>(16, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry eldest) { return size() > SOURCE_CACHE; } }; // IDLE: the tag, set while idling volatile String idleTag; ImapSession(Settings settings, ImapStore store, String remoteIp, boolean tls) { this(settings, store, LoginGuard.disabled(), remoteIp, tls); } ImapSession(Settings settings, ImapStore store, LoginGuard guard, String remoteIp, boolean tls) { this.settings = settings; this.store = store; this.guard = guard; this.remoteIp = remoteIp; this.tls = tls; } byte[] greeting() { return line("* OK [CAPABILITY " + capabilities() + "] " + settings.hostname() + " IMAP4rev1 ready"); } private String capabilities() { StringBuilder caps = new StringBuilder("IMAP4rev1 LITERAL+ SASL-IR ID"); if (account == null) { if (settings.tlsAvailable() && !tls) { caps.append(" STARTTLS"); } if (authAllowed()) { caps.append(" AUTH=PLAIN"); } else { caps.append(" LOGINDISABLED"); } } else { caps.append(" IDLE NAMESPACE UIDPLUS MOVE UNSELECT SPECIAL-USE CHILDREN"); } return caps.toString(); } private boolean authAllowed() { return tls || settings.authWithoutTls(); } boolean idling() { return idleTag != null; } // ===================== dispatch ===================== /** Runs one command (or the SASL response of AUTHENTICATE). Blocking. */ List handle(byte[] command) { List out = new ArrayList<>(); if (pendingAuthTag != null) { String tag = pendingAuthTag; pendingAuthTag = null; authPlainResponse(tag, new ImapReader(command).rest().trim(), out); return out; } ImapReader in = new ImapReader(command); String tag = "*"; try { tag = in.atom(); in.sp(); String name = in.atom().toUpperCase(Locale.ROOT); boolean uid = false; if (name.equals("UID")) { in.sp(); name = in.atom().toUpperCase(Locale.ROOT); uid = true; } execute(tag, name, uid, in, out); } catch (ImapReader.SyntaxError e) { bad(tag, "Syntax error: " + e.getMessage(), out); } catch (ImapStore.Refused e) { out.add(line(tag + " NO " + e.getMessage())); } catch (RuntimeException e) { log.warn("IMAP {}: {} failed: {}", remoteIp, tag, e.toString()); out.add(line(tag + " NO Server error, try again")); } return out; } private void execute(String tag, String name, boolean uid, ImapReader in, List out) { // any state switch (name) { case "CAPABILITY" -> { out.add(line("* CAPABILITY " + capabilities())); ok(tag, "CAPABILITY completed", out); return; } case "NOOP", "CHECK" -> { if (selected != null) { sync(true, out); } ok(tag, name + " completed", out); return; } case "LOGOUT" -> { out.add(line("* BYE " + settings.hostname() + " logging out")); ok(tag, "LOGOUT completed", out); closing = true; return; } case "ID" -> { out.add(line("* ID (\"name\" \"reactive-mail-server\")")); ok(tag, "ID completed", out); return; } default -> { } } if (account == null) { switch (name) { case "STARTTLS" -> startTls(tag, out); case "LOGIN" -> login(tag, in, out); case "AUTHENTICATE" -> authenticate(tag, in, out); default -> bad(tag, "Log in first", out); } return; } switch (name) { case "SELECT", "EXAMINE" -> select(tag, name, in, out); case "CREATE" -> create(tag, in, out); case "DELETE" -> delete(tag, in, out); case "RENAME" -> rename(tag, in, out); case "SUBSCRIBE", "UNSUBSCRIBE" -> { in.sp(); mailbox(in.astring()); ok(tag, name + " completed", out); } case "LIST", "LSUB" -> list(tag, name, in, out); case "NAMESPACE" -> { out.add(line("* NAMESPACE ((\"\" \"/\")) NIL NIL")); ok(tag, "NAMESPACE completed", out); } case "STATUS" -> status(tag, in, out); case "APPEND" -> append(tag, in, out); case "IDLE" -> { // the server loop drives IDLE (see idleStart / idlePoll / idleEnd) bad(tag, "IDLE handled by the connection", out); } case "STARTTLS" -> bad(tag, "Already logged in", out); case "LOGIN", "AUTHENTICATE" -> bad(tag, "Already logged in", out); default -> { if (selected == null) { bad(tag, "Select a mailbox first", out); return; } switch (name) { case "CLOSE" -> close(tag, true, out); case "UNSELECT" -> close(tag, false, out); case "EXPUNGE" -> expunge(tag, uid, in, out); case "SEARCH" -> search(tag, uid, in, out); case "FETCH" -> fetch(tag, uid, in, out); case "STORE" -> store(tag, uid, in, out); case "COPY" -> copyOrMove(tag, uid, false, in, out); case "MOVE" -> copyOrMove(tag, uid, true, in, out); default -> bad(tag, "Unknown command " + name, out); } } } } // ===================== not authenticated ===================== private void startTls(String tag, List out) { if (!settings.tlsAvailable()) { out.add(line(tag + " NO TLS not available")); return; } if (tls) { bad(tag, "TLS already active", out); return; } tls = true; out.add(line(tag + " OK Begin TLS negotiation now")); out.add(STARTTLS_READY); } private void login(String tag, ImapReader in, List out) { in.sp(); String user = in.astring(); in.sp(); String password = in.astring(); if (!authAllowed()) { out.add(line(tag + " NO [PRIVACYREQUIRED] Use STARTTLS first")); return; } checkPassword(tag, user, password, out); } private void authenticate(String tag, ImapReader in, List out) { in.sp(); String mechanism = in.atom().toUpperCase(Locale.ROOT); if (!mechanism.equals("PLAIN")) { out.add(line(tag + " NO Unsupported authentication mechanism")); return; } if (!authAllowed()) { out.add(line(tag + " NO [PRIVACYREQUIRED] Use STARTTLS first")); return; } if (in.optionalSp()) { authPlainResponse(tag, in.atom(), out); return; } pendingAuthTag = tag; out.add(line("+ ")); } private void authPlainResponse(String tag, String response, List out) { if (response.equals("*")) { bad(tag, "Authentication cancelled", out); return; } String[] fields; try { fields = new String(Base64.getDecoder().decode(response), StandardCharsets.UTF_8).split("\0", -1); } catch (IllegalArgumentException e) { bad(tag, "Bad base64", out); return; } if (fields.length != 3) { bad(tag, "Bad PLAIN response", out); return; } checkPassword(tag, fields[1], fields[2], out); } /** Through the login guard: a blocked IP/mailbox gets no answer about the password and the connection closes. */ private void checkPassword(String tag, String user, String password, List out) { Duration wait = guard.retryAfter(remoteIp, user); if (!wait.isZero()) { log.warn("IMAP {}: login as {} refused, blocked for {}", remoteIp, user, LoginGuard.human(wait)); out.add(line("* BYE Too many failed logins, try again in " + LoginGuard.human(wait))); out.add(line(tag + " NO [UNAVAILABLE] Too many failed logins")); closing = true; return; } ImapStore.Account result = store.authenticate(user, password); if (result == null) { guard.failure(remoteIp, user); pause(guard.failureDelay()); } else { guard.success(remoteIp, user); } loggedIn(tag, result, user, out); } private static void pause(Duration delay) { if (delay.isZero()) { return; } try { Thread.sleep(delay.toMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private void loggedIn(String tag, ImapStore.Account result, String user, List out) { if (result == null) { log.warn("IMAP {}: authentication failed for {}", remoteIp, user); if (++badCommands >= MAX_AUTH_FAILURES) { out.add(line("* BYE Too many failed logins")); closing = true; } out.add(line(tag + " NO [AUTHENTICATIONFAILED] Authentication failed")); return; } account = result; log.info("IMAP {}: logged in as {}", remoteIp, account.email()); out.add(line(tag + " OK [CAPABILITY " + capabilities() + "] Logged in")); } // ===================== mailboxes ===================== private ImapStore.Mailbox mailbox(String rawName) { String name = ImapWriter.decodeMailbox(rawName); return store.mailboxes(account.userId()).stream() .filter(m -> m.named(name)) .findFirst() .orElseThrow(() -> new ImapStore.Refused("[NONEXISTENT] No such mailbox")); } private static String nameOf(ImapStore.Mailbox mailbox) { return mailbox.isInbox() ? "INBOX" : ImapWriter.encodeMailbox(mailbox.name()); } private void select(String tag, String name, ImapReader in, List out) { in.sp(); String requested = in.astring(); selected = null; view.clear(); goneUids.clear(); ImapStore.Mailbox mailbox = mailbox(requested); selected = mailbox; readOnly = name.equals("EXAMINE"); view.addAll(store.entries(mailbox.id())); out.add(line("* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)")); out.add(line("* OK [PERMANENTFLAGS " + (readOnly ? "()" : PERMANENT_FLAGS) + "] Flags permitted")); out.add(line("* " + view.size() + " EXISTS")); out.add(line("* 0 RECENT")); for (int i = 0; i < view.size(); i++) { if (!view.get(i).seen()) { out.add(line("* OK [UNSEEN " + (i + 1) + "] First unseen")); break; } } out.add(line("* OK [UIDVALIDITY " + mailbox.uidValidity() + "] UIDs valid")); out.add(line("* OK [UIDNEXT " + uidNext(mailbox) + "] Predicted next UID")); ok(tag, (readOnly ? "[READ-ONLY] " : "[READ-WRITE] ") + name + " completed", out); } private long uidNext(ImapStore.Mailbox mailbox) { return store.mailboxes(account.userId()).stream().filter(m -> m.id() == mailbox.id()) .mapToLong(ImapStore.Mailbox::uidNext).findFirst().orElse(mailbox.uidNext()); } private void create(String tag, ImapReader in, List out) { in.sp(); String name = ImapWriter.decodeMailbox(in.astring()); if (name.endsWith("/")) { name = name.substring(0, name.length() - 1); } if (name.contains("/")) { out.add(line(tag + " NO [CANNOT] Sub-folders are not supported")); return; } String finalName = name; if (store.mailboxes(account.userId()).stream().anyMatch(m -> m.named(finalName))) { out.add(line(tag + " NO [ALREADYEXISTS] Mailbox already exists")); return; } store.create(account.userId(), name); ok(tag, "CREATE completed", out); } private void delete(String tag, ImapReader in, List out) { in.sp(); ImapStore.Mailbox mailbox = mailbox(in.astring()); if (mailbox.system()) { out.add(line(tag + " NO [CANNOT] System folders cannot be deleted")); return; } if (selected != null && selected.id() == mailbox.id()) { selected = null; view.clear(); } store.delete(account.userId(), mailbox); ok(tag, "DELETE completed", out); } private void rename(String tag, ImapReader in, List out) { in.sp(); ImapStore.Mailbox mailbox = mailbox(in.astring()); in.sp(); String newName = ImapWriter.decodeMailbox(in.astring()); if (mailbox.system()) { out.add(line(tag + " NO [CANNOT] System folders cannot be renamed")); return; } if (newName.contains("/")) { out.add(line(tag + " NO [CANNOT] Sub-folders are not supported")); return; } store.rename(account.userId(), mailbox, newName); ok(tag, "RENAME completed", out); } private void list(String tag, String name, ImapReader in, List out) { in.sp(); boolean specialOnly = false; if (in.peek() == '(') { // RFC 5258 selection options specialOnly = in.list().stream().anyMatch(o -> o.equalsIgnoreCase("SPECIAL-USE")); in.sp(); } String reference = ImapWriter.decodeMailbox(in.astring()); in.sp(); List patterns = in.peek() == '(' ? in.list() : List.of(in.astring()); if (patterns.size() == 1 && patterns.get(0).isEmpty()) { out.add(line("* " + name + " (\\Noselect) \"/\" \"\"")); ok(tag, name + " completed", out); return; } List mailboxes = new ArrayList<>(store.mailboxes(account.userId())); mailboxes.sort(Comparator.comparing((ImapStore.Mailbox m) -> !m.isInbox()).thenComparing(m -> m.name().toLowerCase(Locale.ROOT))); for (ImapStore.Mailbox mailbox : mailboxes) { String shown = mailbox.isInbox() ? "INBOX" : mailbox.name(); boolean matches = patterns.stream() .map(p -> reference + ImapWriter.decodeMailbox(p)) .anyMatch(p -> pattern(p).matcher(shown).matches() || (mailbox.isInbox() && pattern(p.toUpperCase(Locale.ROOT)).matcher("INBOX").matches())); if (!matches || (specialOnly && mailbox.specialUse() == null)) { continue; } String attrs = "\\HasNoChildren" + (mailbox.specialUse() == null ? "" : " " + mailbox.specialUse()); out.add(new ImapWriter().text("* " + name + " (" + attrs + ") \"/\" ").string(nameOf(mailbox)).crlf().bytes()); } ok(tag, name + " completed", out); } private static Pattern pattern(String imapPattern) { StringBuilder regex = new StringBuilder(); for (char c : imapPattern.toCharArray()) { switch (c) { case '*' -> regex.append(".*"); case '%' -> regex.append("[^/]*"); default -> regex.append(Pattern.quote(String.valueOf(c))); } } return Pattern.compile(regex.toString(), Pattern.CASE_INSENSITIVE); } private void status(String tag, ImapReader in, List out) { in.sp(); ImapStore.Mailbox mailbox = mailbox(in.astring()); in.sp(); List items = in.list(); List entries = store.entries(mailbox.id()); List values = new ArrayList<>(); for (String item : items) { switch (item.toUpperCase(Locale.ROOT)) { case "MESSAGES" -> values.add("MESSAGES " + entries.size()); case "RECENT" -> values.add("RECENT 0"); case "UIDNEXT" -> values.add("UIDNEXT " + mailbox.uidNext()); case "UIDVALIDITY" -> values.add("UIDVALIDITY " + mailbox.uidValidity()); case "UNSEEN" -> values.add("UNSEEN " + entries.stream().filter(e -> !e.seen()).count()); default -> throw new ImapReader.SyntaxError("Unknown STATUS item " + item); } } out.add(new ImapWriter().text("* STATUS ").string(nameOf(mailbox)).text(" (" + String.join(" ", values) + ")").crlf().bytes()); ok(tag, "STATUS completed", out); } private void append(String tag, ImapReader in, List out) { in.sp(); String rawName = in.astring(); in.sp(); Flags flags = new Flags(); if (in.peek() == '(') { for (String flag : in.list()) { flags.set(flag, true); } in.sp(); } Instant date = null; if (in.peek() == '"') { String text = new String(in.stringBytes(), StandardCharsets.US_ASCII); try { date = ZonedDateTime.parse(text.trim(), INTERNAL_DATE).toInstant(); } catch (DateTimeParseException e) { bad(tag, "Bad date-time", out); return; } in.sp(); } byte[] message = in.stringBytes(); ImapStore.Mailbox target; try { target = mailbox(rawName); } catch (ImapStore.Refused e) { out.add(line(tag + " NO [TRYCREATE] No such mailbox")); return; } long uid = store.append(account.userId(), target, message, flags.seen, flags.flagged, flags.answered, flags.deleted, date); if (selected != null && selected.id() == target.id()) { sync(false, out); } ok(tag, "[APPENDUID " + target.uidValidity() + " " + uid + "] APPEND completed", out); } // ===================== selected ===================== private void close(String tag, boolean expunge, List out) { if (expunge && !readOnly) { List ids = store.entries(selected.id()).stream().filter(ImapStore.Entry::deleted) .map(ImapStore.Entry::id).toList(); store.expunge(ids); } selected = null; view.clear(); goneUids.clear(); ok(tag, (expunge ? "CLOSE" : "UNSELECT") + " completed", out); } private void expunge(String tag, boolean uid, ImapReader in, List out) { if (readOnly) { out.add(line(tag + " NO Mailbox is read-only")); return; } Set allowed = null; if (uid) { in.sp(); allowed = new HashSet<>(); for (int index : resolve(in.atom(), true)) { allowed.add(view.get(index).uid()); } } Set finalAllowed = allowed; List ids = store.entries(selected.id()).stream() .filter(ImapStore.Entry::deleted) .filter(e -> finalAllowed == null || finalAllowed.contains(e.uid())) .map(ImapStore.Entry::id).toList(); store.expunge(ids); sync(true, out); ok(tag, "EXPUNGE completed", out); } private void copyOrMove(String tag, boolean uid, boolean move, ImapReader in, List out) { in.sp(); List indexes = resolve(in.atom(), uid); in.sp(); ImapStore.Mailbox target; try { target = mailbox(in.astring()); } catch (ImapStore.Refused e) { out.add(line(tag + " NO [TRYCREATE] No such mailbox")); return; } if (move && readOnly) { out.add(line(tag + " NO Mailbox is read-only")); return; } List entries = indexes.stream().map(view::get).filter(e -> !goneUids.contains(e.uid())).toList(); if (entries.isEmpty()) { ok(tag, (move ? "MOVE" : "COPY") + " completed", out); return; } List newUids = move ? store.move(account.userId(), entries, target) : store.copy(account.userId(), entries, target); String code = "[COPYUID " + target.uidValidity() + " " + uidSet(entries.stream().map(ImapStore.Entry::uid).toList()) + " " + uidSet(newUids) + "]"; if (move) { out.add(line("* OK " + code + " Moved")); sync(true, out); ok(tag, "MOVE completed", out); } else { ok(tag, code + " COPY completed", out); } } private void store(String tag, boolean uid, ImapReader in, List out) { in.sp(); List indexes = resolve(in.atom(), uid); in.sp(); String item = in.atom().toUpperCase(Locale.ROOT); in.sp(); List flagNames = in.list(); if (readOnly) { out.add(line(tag + " NO Mailbox is read-only")); return; } boolean silent = item.endsWith(".SILENT"); String mode = silent ? item.substring(0, item.length() - ".SILENT".length()) : item; for (int index : indexes) { ImapStore.Entry entry = view.get(index); if (goneUids.contains(entry.uid())) { continue; } Flags flags = mode.equals("FLAGS") ? new Flags() : new Flags(entry); for (String flag : flagNames) { flags.set(flag, !mode.equals("-FLAGS")); } store.setFlags(entry.id(), flags.seen, flags.flagged, flags.answered, flags.deleted); ImapStore.Entry updated = entry.withFlags(flags.seen, flags.flagged, flags.answered, flags.deleted); view.set(index, updated); if (!silent) { out.add(line("* " + (index + 1) + " FETCH (FLAGS " + flags(updated) + (uid ? " UID " + entry.uid() : "") + ")")); } } sync(uid, out); ok(tag, "STORE completed", out); } // ===================== FETCH ===================== private record FetchItem(String name, String section, boolean peek, Integer partialStart, Integer partialLength, String echo) { } private void fetch(String tag, boolean uid, ImapReader in, List out) { in.sp(); List indexes = resolve(in.atom(), uid); in.sp(); List items = fetchItems(in.rest().trim()); if (uid && items.stream().noneMatch(i -> i.name().equals("UID"))) { List withUid = new ArrayList<>(); withUid.add(new FetchItem("UID", null, false, null, null, "UID")); withUid.addAll(items); items = withUid; } for (int index : indexes) { ImapStore.Entry entry = view.get(index); if (goneUids.contains(entry.uid())) { continue; } out.add(fetchOne(index, entry, items)); } sync(uid, out); ok(tag, "FETCH completed", out); } private byte[] fetchOne(int index, ImapStore.Entry entry, List items) { ImapWriter w = new ImapWriter().text("* " + (index + 1) + " FETCH ("); boolean first = true; boolean setSeen = false; MimeTree tree = null; for (FetchItem item : items) { if (!first) { w.sp(); } first = false; switch (item.name()) { case "UID" -> w.text("UID " + entry.uid()); case "FLAGS" -> w.text("FLAGS " + flags(entry)); case "INTERNALDATE" -> w.text("INTERNALDATE \"" + INTERNAL_DATE.format( entry.internalDate().atZone(ZoneOffset.UTC)) + "\""); case "RFC822.SIZE" -> w.text("RFC822.SIZE " + source(entry).length); case "ENVELOPE" -> { tree = tree != null ? tree : MimeTree.parse(source(entry)); w.text("ENVELOPE ").envelope(tree); } case "BODYSTRUCTURE", "BODY" -> { tree = tree != null ? tree : MimeTree.parse(source(entry)); w.text(item.name() + " ").bodyStructure(tree, item.name().equals("BODYSTRUCTURE")); } case "RFC822", "RFC822.HEADER", "RFC822.TEXT", "BODY[]" -> { tree = tree != null ? tree : MimeTree.parse(source(entry)); byte[] data = section(tree, item.section()); if (item.partialStart() != null) { int startAt = Math.min(item.partialStart(), data.length); int length = Math.min(item.partialLength(), data.length - startAt); data = java.util.Arrays.copyOfRange(data, startAt, startAt + length); } w.text(item.echo() + " ").literal(data); if (!item.peek()) { setSeen = true; } } default -> throw new ImapReader.SyntaxError("Unknown FETCH item " + item.name()); } } if (setSeen && !entry.seen() && !readOnly) { store.setFlags(entry.id(), true, entry.flagged(), entry.answered(), entry.deleted()); ImapStore.Entry updated = entry.withFlags(true, entry.flagged(), entry.answered(), entry.deleted()); view.set(index, updated); if (items.stream().noneMatch(i -> i.name().equals("FLAGS"))) { w.sp().text("FLAGS " + flags(updated)); } } return w.text(")").crlf().bytes(); } /** * BODY[section] content (RFC 3501 6.4.5): part numbers ("2.1"), then HEADER, HEADER.FIELDS[.NOT] (…), TEXT or MIME. * Part 1 of a non-multipart message is its body; numbers after a message/rfc822 part go into that message. */ static byte[] section(MimeTree root, String section) { String spec = section == null ? "" : section.trim(); String upper = spec.toUpperCase(Locale.ROOT); MimeTree part = null; // the part chosen by the numbers; null — the whole message boolean messageBody = false; // part 1 of a non-multipart message int i = 0; while (i < upper.length() && Character.isDigit(upper.charAt(i))) { int dot = upper.indexOf('.', i); int number = Integer.parseInt(upper.substring(i, dot < 0 ? upper.length() : dot)); i = dot < 0 ? upper.length() : dot + 1; MimeTree container = part == null ? root : part.message != null ? part.message : part; boolean containerIsMessage = part == null || part.message != null; if (messageBody) { return new byte[0]; // nothing below the body of a single-part message } if (container.isMultipart()) { if (number < 1 || number > container.children.size()) { return new byte[0]; } part = container.children.get(number - 1); } else if (containerIsMessage && number == 1) { part = container; messageBody = true; } else { return new byte[0]; } } String rest = upper.substring(i); if (rest.isEmpty()) { if (part == null) { return root.all(); } return part.body(); } if (rest.equals("MIME")) { return part == null ? new byte[0] : part.header(); } MimeTree message = part == null ? root : messageBody ? null : part.message; if (message == null) { return new byte[0]; } if (rest.equals("HEADER")) { return message.header(); } if (rest.equals("TEXT")) { return message.body(); } if (rest.startsWith("HEADER.FIELDS")) { boolean not = rest.startsWith("HEADER.FIELDS.NOT"); int open = rest.indexOf('('); int close = rest.lastIndexOf(')'); Set names = new HashSet<>(); if (open >= 0 && close > open) { for (String name : rest.substring(open + 1, close).trim().split("\\s+")) { if (!name.isEmpty()) { names.add(name.replace("\"", "")); } } } return message.headerFields(names, not); } throw new ImapReader.SyntaxError("Bad section " + section); } private static List fetchItems(String text) { String trimmed = text.trim(); String upper = trimmed.toUpperCase(Locale.ROOT); switch (upper) { case "ALL" -> trimmed = "(FLAGS INTERNALDATE RFC822.SIZE ENVELOPE)"; case "FAST" -> trimmed = "(FLAGS INTERNALDATE RFC822.SIZE)"; case "FULL" -> trimmed = "(FLAGS INTERNALDATE RFC822.SIZE ENVELOPE BODY)"; default -> { } } if (trimmed.startsWith("(")) { if (!trimmed.endsWith(")")) { throw new ImapReader.SyntaxError("Unbalanced FETCH list"); } trimmed = trimmed.substring(1, trimmed.length() - 1); } List items = new ArrayList<>(); int i = 0; while (i < trimmed.length()) { while (i < trimmed.length() && trimmed.charAt(i) == ' ') { i++; } if (i >= trimmed.length()) { break; } int start = i; while (i < trimmed.length() && trimmed.charAt(i) != ' ' && trimmed.charAt(i) != '[') { i++; } String name = trimmed.substring(start, i).toUpperCase(Locale.ROOT); String section = null; Integer partialStart = null; Integer partialLength = null; if (i < trimmed.length() && trimmed.charAt(i) == '[') { int close = trimmed.indexOf(']', i); if (close < 0) { throw new ImapReader.SyntaxError("Unterminated section"); } section = trimmed.substring(i + 1, close); i = close + 1; if (i < trimmed.length() && trimmed.charAt(i) == '<') { int end = trimmed.indexOf('>', i); String[] range = trimmed.substring(i + 1, end).split("\\."); partialStart = Integer.parseInt(range[0]); partialLength = range.length > 1 ? Integer.parseInt(range[1]) : Integer.MAX_VALUE; i = end + 1; } } switch (name) { case "BODY", "BODY.PEEK" -> { if (section == null) { if (name.equals("BODY.PEEK")) { throw new ImapReader.SyntaxError("BODY.PEEK needs a section"); } items.add(new FetchItem("BODY", null, true, null, null, "BODY")); } else { String echo = "BODY[" + section + "]" + (partialStart != null ? "<" + partialStart + ">" : ""); items.add(new FetchItem("BODY[]", section, name.equals("BODY.PEEK"), partialStart, partialLength, echo)); } } case "BINARY", "BINARY.PEEK" -> throw new ImapReader.SyntaxError("BINARY is not supported"); case "RFC822" -> items.add(new FetchItem("RFC822", "", false, null, null, "RFC822")); case "RFC822.HEADER" -> items.add(new FetchItem("RFC822.HEADER", "HEADER", true, null, null, "RFC822.HEADER")); case "RFC822.TEXT" -> items.add(new FetchItem("RFC822.TEXT", "TEXT", false, null, null, "RFC822.TEXT")); case "UID", "FLAGS", "INTERNALDATE", "RFC822.SIZE", "ENVELOPE", "BODYSTRUCTURE" -> items.add(new FetchItem(name, null, true, null, null, name)); default -> throw new ImapReader.SyntaxError("Unknown FETCH item " + name); } } if (items.isEmpty()) { throw new ImapReader.SyntaxError("Nothing to FETCH"); } return items; } private byte[] source(ImapStore.Entry entry) { return sourceCache.computeIfAbsent(entry.id(), store::source); } // ===================== SEARCH ===================== private void search(String tag, boolean uid, ImapReader in, List out) { in.sp(); SearchParser parser = new SearchParser(in); Predicate criteria = parser.all(); StringBuilder result = new StringBuilder("* SEARCH"); for (int i = 0; i < view.size(); i++) { ImapStore.Entry entry = view.get(i); if (!goneUids.contains(entry.uid()) && criteria.test(i)) { result.append(' ').append(uid ? entry.uid() : i + 1); } } out.add(line(result.toString())); sync(uid, out); ok(tag, "SEARCH completed", out); } /** Search keys of RFC 3501 6.4.4, evaluated over the selected mailbox. */ private final class SearchParser { private final ImapReader in; SearchParser(ImapReader in) { this.in = in; } Predicate all() { List> keys = new ArrayList<>(); while (!in.atEnd() && in.peek() != ')') { keys.add(key()); in.optionalSp(); } return index -> keys.stream().allMatch(k -> k.test(index)); } private Predicate key() { if (in.peek() == '(') { in.expect('('); Predicate group = all(); in.expect(')'); return group; } String word = in.atom(); String key = word.toUpperCase(Locale.ROOT); if (Character.isDigit(key.charAt(0)) || key.charAt(0) == '*') { Set set = new HashSet<>(resolve(word, false)); return set::contains; } return switch (key) { case "CHARSET" -> { in.sp(); in.astring(); yield i -> true; } case "ALL", "OLD" -> i -> true; case "NEW", "RECENT" -> i -> false; case "SEEN" -> i -> e(i).seen(); case "UNSEEN" -> i -> !e(i).seen(); case "FLAGGED" -> i -> e(i).flagged(); case "UNFLAGGED" -> i -> !e(i).flagged(); case "ANSWERED" -> i -> e(i).answered(); case "UNANSWERED" -> i -> !e(i).answered(); case "DELETED" -> i -> e(i).deleted(); case "UNDELETED" -> i -> !e(i).deleted(); case "DRAFT" -> i -> selected.isDrafts(); case "UNDRAFT" -> i -> !selected.isDrafts(); case "KEYWORD" -> { arg(); yield i -> false; } case "UNKEYWORD" -> { arg(); yield i -> true; } case "NOT" -> { in.sp(); Predicate inner = key(); yield inner.negate(); } case "OR" -> { in.sp(); Predicate a = key(); in.sp(); Predicate b = key(); yield a.or(b); } case "UID" -> { in.sp(); Set set = new HashSet<>(resolve(in.atom(), true)); yield set::contains; } case "LARGER" -> { long n = Long.parseLong(arg()); yield i -> source(e(i)).length > n; } case "SMALLER" -> { long n = Long.parseLong(arg()); yield i -> source(e(i)).length < n; } case "BEFORE", "ON", "SINCE" -> { LocalDate date = date(arg()); yield i -> compare(e(i).internalDate(), date, key); } case "SENTBEFORE", "SENTON", "SENTSINCE" -> { LocalDate date = date(arg()); String op = key.substring(4); yield i -> { Instant sent = sentDate(i); return sent != null && compare(sent, date, op); }; } case "FROM", "TO", "CC", "BCC", "SUBJECT" -> { String needle = arg().toLowerCase(Locale.ROOT); String header = key.charAt(0) + key.substring(1).toLowerCase(Locale.ROOT); yield i -> headerValue(i, header).contains(needle); } case "HEADER" -> { String field = arg(); String needle = arg().toLowerCase(Locale.ROOT); yield i -> { String value = tree(i).header(field); return value != null && (needle.isEmpty() || decode(value).toLowerCase(Locale.ROOT).contains(needle)); }; } case "BODY" -> { String needle = arg().toLowerCase(Locale.ROOT); yield i -> bodyText(i).contains(needle); } case "TEXT" -> { String needle = arg().toLowerCase(Locale.ROOT); yield i -> bodyText(i).contains(needle) || decode(new String(tree(i).header(), StandardCharsets.UTF_8)).toLowerCase(Locale.ROOT).contains(needle); } default -> throw new ImapReader.SyntaxError("Unknown SEARCH key " + word); }; } private String arg() { in.sp(); return in.astring(); } private ImapStore.Entry e(int index) { return view.get(index); } private MimeTree tree(int index) { return MimeTree.parse(source(e(index))); } private String headerValue(int index, String name) { String value = tree(index).header(name); return value == null ? "" : decode(value).toLowerCase(Locale.ROOT); } private String bodyText(int index) { try { MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(source(e(index)))); return LocalDelivery.body(mime).text().toLowerCase(Locale.ROOT); } catch (Exception ex) { return new String(tree(index).body(), StandardCharsets.UTF_8).toLowerCase(Locale.ROOT); } } private Instant sentDate(int index) { String value = tree(index).header("Date"); if (value == null) { return null; } try { return ZonedDateTime.parse(value.replaceAll("\\s*\\(.*\\)$", "").trim(), DateTimeFormatter.RFC_1123_DATE_TIME).toInstant(); } catch (DateTimeParseException ex) { return null; } } } private static LocalDate date(String text) { try { return LocalDate.parse(text.replace("\"", ""), SEARCH_DATE); } catch (DateTimeParseException e) { throw new ImapReader.SyntaxError("Bad date " + text); } } private static boolean compare(Instant instant, LocalDate date, String op) { LocalDate day = instant.atZone(ZoneOffset.UTC).toLocalDate(); return switch (op) { case "BEFORE" -> day.isBefore(date); case "ON" -> day.isEqual(date); default -> !day.isBefore(date); }; } private static String decode(String value) { try { return MimeUtility.decodeText(value); } catch (Exception e) { return value; } } // ===================== IDLE ===================== /** Starts IDLE: the response "+ idling". */ List idleStart(String tag) { List out = new ArrayList<>(); if (account == null) { bad(tag, "Log in first", out); return out; } idleTag = tag; out.add(line("+ idling")); return out; } /** Changes since the last look (EXISTS, EXPUNGE, FLAGS). Blocking. */ List idlePoll() { List out = new ArrayList<>(); if (selected != null) { sync(true, out); } return out; } List idleEnd() { List out = new ArrayList<>(); String tag = idleTag; idleTag = null; if (tag != null) { ok(tag, "IDLE terminated", out); } return out; } // ===================== state sync ===================== /** * Tells the client what changed in the selected mailbox: new messages (EXISTS), flags changed elsewhere (FETCH), * and — when allowed — removed ones (EXPUNGE). EXPUNGE is not allowed during FETCH/STORE/SEARCH by sequence number * (RFC 3501 7.4.1): removed messages then stay in the view until the next allowed point. */ private void sync(boolean allowExpunge, List out) { List current = store.entries(selected.id()); Map byUid = new HashMap<>(); for (ImapStore.Entry entry : current) { byUid.put(entry.uid(), entry); } for (int i = view.size() - 1; i >= 0; i--) { long uid = view.get(i).uid(); if (!byUid.containsKey(uid)) { if (allowExpunge) { view.remove(i); goneUids.remove(uid); out.add(line("* " + (i + 1) + " EXPUNGE")); } else { goneUids.add(uid); } } } long maxUid = view.isEmpty() ? 0 : view.get(view.size() - 1).uid(); for (int i = 0; i < view.size(); i++) { ImapStore.Entry known = view.get(i); ImapStore.Entry now = byUid.get(known.uid()); if (now != null && (now.seen() != known.seen() || now.flagged() != known.flagged() || now.answered() != known.answered() || now.deleted() != known.deleted())) { view.set(i, now); out.add(line("* " + (i + 1) + " FETCH (FLAGS " + flags(now) + " UID " + now.uid() + ")")); } } int before = view.size(); TreeSet known = new TreeSet<>(); view.forEach(e -> known.add(e.uid())); for (ImapStore.Entry entry : current) { if (entry.uid() > maxUid && !known.contains(entry.uid())) { view.add(entry); } } if (view.size() != before) { out.add(line("* " + view.size() + " EXISTS")); } } // ===================== helpers ===================== /** Sequence set → 0-based indexes into the view, ascending. */ private List resolve(String set, boolean uid) { TreeSet result = new TreeSet<>(); if (view.isEmpty()) { return new ArrayList<>(result); } long star = uid ? view.get(view.size() - 1).uid() : view.size(); for (String range : set.split(",")) { String[] ends = range.split(":", 2); long a = number(ends[0], star); long b = ends.length > 1 ? number(ends[1], star) : a; long low = Math.min(a, b); long high = Math.max(a, b); if (uid) { for (int i = 0; i < view.size(); i++) { long u = view.get(i).uid(); if (u >= low && u <= high) { result.add(i); } } } else { for (long n = Math.max(1, low); n <= Math.min(high, view.size()); n++) { result.add((int) n - 1); } } } return new ArrayList<>(result); } private static long number(String text, long star) { if (text.equals("*")) { return star; } try { long n = Long.parseLong(text); if (n <= 0) { throw new ImapReader.SyntaxError("Bad sequence number " + text); } return n; } catch (NumberFormatException e) { throw new ImapReader.SyntaxError("Bad sequence set " + text); } } /** "1:3,7" for UIDPLUS responses. */ private static String uidSet(List uids) { StringBuilder out = new StringBuilder(); for (Long uid : uids) { if (out.length() > 0) { out.append(','); } out.append(uid); } return out.toString(); } private String flags(ImapStore.Entry entry) { List flags = new ArrayList<>(); if (entry.seen()) { flags.add("\\Seen"); } if (entry.flagged()) { flags.add("\\Flagged"); } if (entry.answered()) { flags.add("\\Answered"); } if (entry.deleted()) { flags.add("\\Deleted"); } if (selected != null && selected.isDrafts()) { flags.add("\\Draft"); } return "(" + String.join(" ", flags) + ")"; } private static final class Flags { boolean seen; boolean flagged; boolean answered; boolean deleted; Flags() { } Flags(ImapStore.Entry entry) { seen = entry.seen(); flagged = entry.flagged(); answered = entry.answered(); deleted = entry.deleted(); } void set(String flag, boolean value) { switch (flag.toUpperCase(Locale.ROOT)) { case "\\SEEN" -> seen = value; case "\\FLAGGED" -> flagged = value; case "\\ANSWERED" -> answered = value; case "\\DELETED" -> deleted = value; default -> { // \Draft follows the folder; keywords are not kept } } } } private void ok(String tag, String text, List out) { out.add(line(tag + " OK " + text)); } private void bad(String tag, String text, List out) { if (++badCommands > MAX_BAD_COMMANDS) { out.add(line("* BYE Too many errors")); closing = true; } out.add(line(tag + " BAD " + text)); } static byte[] line(String text) { return (text + "\r\n").getBytes(StandardCharsets.UTF_8); } }