continuous-integration/drone/pr Build is passing
Один LoginGuard на все проверки пароля (SMTP AUTH, IMAP LOGIN/AUTHENTICATE, вход в веб, смена пароля): 10 неверных с IP за 15 мин → блок 15 мин, дальше вдвое дольше до суток; 20 неверных на ящик с разных IP → ящик закрыт, кроме IP, с которых в него уже входили. Во время блока пароль не проверяется (SMTP 421, IMAP BYE + NO [UNAVAILABLE], веб 429 + Retry-After); неверный пароль — ответ через 1 с; 3 неверных в соединении — обрыв. X-Forwarded-For веб-входа — только от прокси в частной сети, иначе заголовок отбрасывается (подменой IP лимит не обойти). Попутно: IMAP терял тегированный ответ после "* BYE" (LOGOUT, обрыв после неудачных входов) — takeUntil теперь после тегированной строки. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1288 lines
50 KiB
Java
1288 lines
50 KiB
Java
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<ImapStore.Entry> view = new ArrayList<>();
|
|
private final Set<Long> goneUids = new HashSet<>();
|
|
|
|
private final LinkedHashMap<Long, byte[]> sourceCache = new LinkedHashMap<>(16, 0.75f, true) {
|
|
@Override
|
|
protected boolean removeEldestEntry(Map.Entry<Long, byte[]> 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<byte[]> handle(byte[] command) {
|
|
List<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<String> 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<ImapStore.Mailbox> 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<byte[]> out) {
|
|
in.sp();
|
|
ImapStore.Mailbox mailbox = mailbox(in.astring());
|
|
in.sp();
|
|
List<String> items = in.list();
|
|
List<ImapStore.Entry> entries = store.entries(mailbox.id());
|
|
List<String> 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<byte[]> 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<byte[]> out) {
|
|
if (expunge && !readOnly) {
|
|
List<Long> 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<byte[]> out) {
|
|
if (readOnly) {
|
|
out.add(line(tag + " NO Mailbox is read-only"));
|
|
return;
|
|
}
|
|
Set<Long> allowed = null;
|
|
if (uid) {
|
|
in.sp();
|
|
allowed = new HashSet<>();
|
|
for (int index : resolve(in.atom(), true)) {
|
|
allowed.add(view.get(index).uid());
|
|
}
|
|
}
|
|
Set<Long> finalAllowed = allowed;
|
|
List<Long> 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<byte[]> out) {
|
|
in.sp();
|
|
List<Integer> 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<ImapStore.Entry> 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<Long> 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<byte[]> out) {
|
|
in.sp();
|
|
List<Integer> indexes = resolve(in.atom(), uid);
|
|
in.sp();
|
|
String item = in.atom().toUpperCase(Locale.ROOT);
|
|
in.sp();
|
|
List<String> 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<byte[]> out) {
|
|
in.sp();
|
|
List<Integer> indexes = resolve(in.atom(), uid);
|
|
in.sp();
|
|
List<FetchItem> items = fetchItems(in.rest().trim());
|
|
if (uid && items.stream().noneMatch(i -> i.name().equals("UID"))) {
|
|
List<FetchItem> 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<FetchItem> 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<String> 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<FetchItem> 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<FetchItem> 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<byte[]> out) {
|
|
in.sp();
|
|
SearchParser parser = new SearchParser(in);
|
|
Predicate<Integer> 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<Integer> all() {
|
|
List<Predicate<Integer>> keys = new ArrayList<>();
|
|
while (!in.atEnd() && in.peek() != ')') {
|
|
keys.add(key());
|
|
in.optionalSp();
|
|
}
|
|
return index -> keys.stream().allMatch(k -> k.test(index));
|
|
}
|
|
|
|
private Predicate<Integer> key() {
|
|
if (in.peek() == '(') {
|
|
in.expect('(');
|
|
Predicate<Integer> 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<Integer> 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<Integer> inner = key();
|
|
yield inner.negate();
|
|
}
|
|
case "OR" -> {
|
|
in.sp();
|
|
Predicate<Integer> a = key();
|
|
in.sp();
|
|
Predicate<Integer> b = key();
|
|
yield a.or(b);
|
|
}
|
|
case "UID" -> {
|
|
in.sp();
|
|
Set<Integer> 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<byte[]> idleStart(String tag) {
|
|
List<byte[]> 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<byte[]> idlePoll() {
|
|
List<byte[]> out = new ArrayList<>();
|
|
if (selected != null) {
|
|
sync(true, out);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
List<byte[]> idleEnd() {
|
|
List<byte[]> 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<byte[]> out) {
|
|
List<ImapStore.Entry> current = store.entries(selected.id());
|
|
Map<Long, ImapStore.Entry> 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<Long> 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<Integer> resolve(String set, boolean uid) {
|
|
TreeSet<Integer> 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<Long> 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<String> 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<byte[]> out) {
|
|
out.add(line(tag + " OK " + text));
|
|
}
|
|
|
|
private void bad(String tag, String text, List<byte[]> 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);
|
|
}
|
|
}
|