{@link CommandDecoder} collects whole commands with their literals ({n} — after "+ Ready", {n+} — at once);
*
each command runs on a worker thread (database, MIME parsing), never on the event loop; responses are written
* in order, one command at a time;
*
TLS: STARTTLS, or implicit TLS on a separate port (993); LOGIN/AUTHENTICATE only inside TLS unless
* {@code authWithoutTls}; plain text sent together with STARTTLS is never executed;
*
IDLE: changes of the selected mailbox are pushed every {@code idlePoll} until DONE.
*
*/
public class ImapServer {
private static final Logger log = LoggerFactory.getLogger(ImapServer.class);
static final int MAX_LINE = 64 * 1024;
private static final int MAX_QUEUED = 1000;
private static final Pattern LITERAL = Pattern.compile("\\{(\\d+)(\\+?)}\r?\n$");
private static final Pattern IDLE = Pattern.compile("^(\\S+) IDLE\\s*$", Pattern.CASE_INSENSITIVE);
private static final Duration IDLE_MAX = Duration.ofMinutes(29);
/**
* @param tls certificate; null — no TLS
* @param implicitTls TLS from the first byte (port 993)
* @param maxLiteral the largest literal accepted (APPEND of a big message)
* @param idlePoll how often IDLE looks for changes
*/
public record Settings(String host, int port, String hostname, boolean authWithoutTls, SslContext tls,
boolean implicitTls, int maxLiteral, Duration idleTimeout, Duration idlePoll) {
}
/** A whole command and whether it arrived over TLS. */
record Command(byte[] bytes, boolean tls) {
}
private final Settings settings;
private final ImapStore store;
private final LoginGuard guard;
private DisposableServer server;
public ImapServer(Settings settings, ImapStore store) {
this(settings, store, LoginGuard.disabled());
}
public ImapServer(Settings settings, ImapStore store, LoginGuard guard) {
this.settings = settings;
this.store = store;
this.guard = guard;
}
public void start() {
if (settings.implicitTls() && settings.tls() == null) {
throw new IllegalStateException("Implicit TLS needs a certificate");
}
TcpServer tcp = TcpServer.create()
.host(settings.host())
.port(settings.port())
.doOnConnection(connection -> connection
.addHandlerFirst("imap-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS))
.addHandlerLast("imap-commands", new CommandDecoder(settings.maxLiteral())));
if (settings.implicitTls()) {
tcp = tcp.secure(spec -> spec.sslContext(settings.tls()));
}
server = tcp.handle((inbound, outbound) -> {
Connection[] connection = {null};
String[] remoteIp = {"unknown"};
inbound.withConnection(c -> {
connection[0] = c;
if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) {
remoteIp[0] = address.getAddress().getHostAddress();
}
});
ImapSession session = new ImapSession(
new ImapSession.Settings(settings.hostname(), settings.tls() != null, settings.authWithoutTls()),
store, guard, remoteIp[0], settings.implicitTls());
@SuppressWarnings("unchecked")
Sinks.Empty[] idleDone = new Sinks.Empty[1];
Flux responses = inbound.receiveObject().cast(Command.class)
// DONE ends IDLE while the IDLE command is still running — handled before the queue
.filter(command -> {
if (session.idling() && isDone(command.bytes())) {
if (idleDone[0] != null) {
idleDone[0].tryEmitEmpty();
}
return false;
}
return true;
})
// read commands as they come (so DONE is seen during IDLE), run them one by one
.onBackpressureBuffer(MAX_QUEUED, dropped -> log.warn("IMAP {}: too many queued commands", session.remoteIp))
.concatMap(command -> {
if (session.tls && !command.tls()) {
// plain text sent together with STARTTLS: never executed, the connection is closed
log.warn("IMAP {}: plain-text command after STARTTLS, closing", session.remoteIp);
session.closing = true;
connection[0].dispose();
return Flux.empty();
}
String text = new String(command.bytes(), StandardCharsets.UTF_8).trim();
Matcher idle = IDLE.matcher(text);
if (idle.matches()) {
idleDone[0] = Sinks.empty();
return idle(session, idle.group(1), idleDone[0]);
}
return blocking(() -> session.handle(command.bytes()));
})
.onErrorResume(e -> {
log.debug("IMAP {}: connection closed: {}", session.remoteIp, e.toString());
return Mono.just(ImapSession.line("* BYE " + e.getMessage()));
})
// after the tagged line of the command that closes (LOGOUT, too many failed logins), not after
// its first untagged "* BYE" — the tagged answer must still reach the client
.takeUntil(response -> session.closing && !untagged(response));
return Flux.concat(Mono.just(session.greeting()), responses)
.concatMap(response -> response == ImapSession.STARTTLS_READY
? startTls(connection[0], session)
: send(outbound, response))
.then();
})
.bindNow(Duration.ofSeconds(10));
log.info("IMAP server listening on {}:{} ({}{})", settings.host(), port(), settings.hostname(),
settings.implicitTls() ? ", implicit TLS" : settings.tls() != null ? ", STARTTLS" : ", no TLS");
}
private Flux idle(ImapSession session, String tag, Sinks.Empty done) {
Flux polls = Flux.interval(settings.idlePoll())
.onBackpressureDrop()
.concatMap(tick -> blocking(session::idlePoll))
.takeUntilOther(done.asMono())
.take(IDLE_MAX);
return Flux.concat(
blocking(() -> session.idleStart(tag)),
Flux.defer(() -> session.idling() ? polls : Flux.empty()),
blocking(session::idleEnd));
}
private static boolean isDone(byte[] bytes) {
return new String(bytes, StandardCharsets.US_ASCII).trim().toUpperCase(Locale.ROOT).equals("DONE");
}
private static boolean untagged(byte[] response) {
return response.length > 1 && response[0] == '*' && response[1] == ' ';
}
private static Flux blocking(java.util.concurrent.Callable> work) {
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()).flatMapIterable(list -> list);
}
private static Mono send(NettyOutbound outbound, byte[] response) {
return outbound.send(Mono.just(Unpooled.wrappedBuffer(response))).then();
}
/** After the tagged OK of STARTTLS has left in plain text: TLS in front, a fresh decoder (no plain leftovers). */
private Mono startTls(Connection connection, ImapSession session) {
ChannelPipeline pipeline = connection.channel().pipeline();
SslHandler ssl = settings.tls().newHandler(connection.channel().alloc());
pipeline.replace("imap-commands", "imap-commands", new CommandDecoder(settings.maxLiteral()));
pipeline.addFirst("imap-tls", ssl);
return Mono.create(sink -> ssl.handshakeFuture().addListener(future -> {
if (!future.isSuccess()) {
log.info("IMAP {}: TLS handshake failed: {}", session.remoteIp, future.cause().toString());
session.closing = true;
connection.dispose();
}
sink.success();
}));
}
public int port() {
return server.port();
}
public void stop() {
if (server != null) {
server.disposeNow(Duration.ofSeconds(5));
}
}
/**
* Collects one command: lines plus the literals they announce. A synchronizing literal {n} gets "+ Ready" first;
* {n+} (LITERAL+) is read at once. Marks whether the command was read behind TLS.
*/
static final class CommandDecoder extends ByteToMessageDecoder {
private final int maxLiteral;
private final ByteArrayOutputStream command = new ByteArrayOutputStream();
private int literalLeft;
private int lineBytes;
CommandDecoder(int maxLiteral) {
this.maxLiteral = maxLiteral;
}
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List