From b72be91a8b6ebbf82981e9be0a8c67b6a9e25b9d Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Thu, 5 Nov 2026 20:45:06 +0100 Subject: [PATCH] =?UTF-8?q?75.2=20IMAP:=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=20=D0=BD=D0=B0=20Reacto?= =?UTF-8?q?r=20Netty=20(STARTTLS,=20993,=20IDLE,=20UIDPLUS,=20MOVE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Старый 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]), 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 --- README.md | 16 +- .../com/mailserver/config/ImapConfig.java | 59 + .../mailserver/config/ImapServerConfig.java | 580 -------- .../java/com/mailserver/imap/DbImapStore.java | 285 ++++ .../java/com/mailserver/imap/ImapReader.java | 194 +++ .../java/com/mailserver/imap/ImapServer.java | 260 ++++ .../java/com/mailserver/imap/ImapSession.java | 1246 +++++++++++++++++ .../java/com/mailserver/imap/ImapStore.java | 91 ++ .../java/com/mailserver/imap/ImapWriter.java | 255 ++++ .../java/com/mailserver/imap/MimeSource.java | 129 ++ .../java/com/mailserver/imap/MimeTree.java | 250 ++++ .../repository/MessageRepository.java | 4 - .../com/mailserver/service/LocalDelivery.java | 17 +- .../com/mailserver/service/MailService.java | 12 +- .../mailserver/service/MessageSources.java | 38 + .../src/main/resources/application.properties | 10 +- .../main/resources/db/migration/V4__imap.sql | 43 + .../com/mailserver/imap/ImapServerTest.java | 345 +++++ .../com/mailserver/imap/MemoryImapStore.java | 172 +++ docker-compose.jambotron.yml | 1 + 20 files changed, 3408 insertions(+), 599 deletions(-) create mode 100644 backend/src/main/java/com/mailserver/config/ImapConfig.java delete mode 100644 backend/src/main/java/com/mailserver/config/ImapServerConfig.java create mode 100644 backend/src/main/java/com/mailserver/imap/DbImapStore.java create mode 100644 backend/src/main/java/com/mailserver/imap/ImapReader.java create mode 100644 backend/src/main/java/com/mailserver/imap/ImapServer.java create mode 100644 backend/src/main/java/com/mailserver/imap/ImapSession.java create mode 100644 backend/src/main/java/com/mailserver/imap/ImapStore.java create mode 100644 backend/src/main/java/com/mailserver/imap/ImapWriter.java create mode 100644 backend/src/main/java/com/mailserver/imap/MimeSource.java create mode 100644 backend/src/main/java/com/mailserver/imap/MimeTree.java create mode 100644 backend/src/main/java/com/mailserver/service/MessageSources.java create mode 100644 backend/src/main/resources/db/migration/V4__imap.sql create mode 100644 backend/src/test/java/com/mailserver/imap/ImapServerTest.java create mode 100644 backend/src/test/java/com/mailserver/imap/MemoryImapStore.java diff --git a/README.md b/README.md index b7c1335..8f5a683 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,17 @@ - **DKIM.** Подпись — `utils-mail-dkim` (rsa-sha256, relaxed/relaxed), проверка входящих — Apache James jDKIM по ключу из DNS отправителя. Ключ создаётся один раз в `DKIM_KEYS_DIR`, запись для DNS печатается в лог при старте: `TXT ._domainkey.`. - **Права.** Письма, папки и вложения отдаются только владельцу (чужие — `404`). Регистрация по умолчанию закрыта, ящики — только в своих доменах. +## IMAP + +Почтовые программы (Thunderbird, почта на телефоне) работают с теми же ящиками, что и веб-почта. + +- **Порт** `IMAP_PORT` (3143) со `STARTTLS`, и неявный TLS на `IMAPS_PORT` (993), если задан. Сертификат тот же, что у SMTP. Вход (`LOGIN`, `AUTHENTICATE PLAIN`) — только внутри TLS, без него сервер отвечает `LOGINDISABLED`. +- **Возможности:** IMAP4rev1, LITERAL+, IDLE (новые письма приходят сами, проверка раз в 5 с), UIDPLUS, MOVE, UNSELECT, NAMESPACE, SPECIAL-USE (Отправленные, Черновики, Корзина, Спам клиент находит сам), ID. +- **UID** выдаёт база (триггер): письмо, перенесённое в папку из веба или по IMAP, получает новый, больший UID — клиент его не пропустит. Флаги `\Seen`, `\Flagged`, `\Answered`, `\Deleted`; `\Draft` — у писем в «Черновиках». +- **Письмо целиком** (`BODY[]`) — ровно в том виде, в каком оно пришло по SMTP или через `APPEND`. Для писем, написанных в веб-почте, MIME собирается один раз и сохраняется: размер и байты не меняются между запросами. +- Каждая команда выполняется на рабочем потоке, не на сетевом. Команды, присланные открытым текстом вместе со `STARTTLS`, не выполняются — соединение закрывается. +- Папки — плоские (без вложенных), имена — modified UTF-7, кириллица работает. + ## Веб-почта Angular 22 в стиле [synthwave-шаблона](frontend/src/assets/synthwave-design-showcase.html): стеклянные панели, неон, движущаяся сетка (выключается в настройках и при `prefers-reduced-motion`), четыре палитры и высокий контраст. Все таблицы — грид [`@jambotron/aria-grid`](https://gitea.jambotron.com/jambotron_gang/jambotron-aria-grid) (сортировка, страницы, выбор строк, массовые действия), пакет — из реестра npm Gitea (`frontend/.npmrc`). @@ -64,6 +75,8 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api | `MAIL_TLS_CERT`, `MAIL_TLS_KEY` | свой сертификат: PEM-цепочка и ключ PKCS#8; без них — самоподписанный | — | | `MAIL_TLS_DIR` | где лежит самоподписанный сертификат | `./tls` (в образе `/app/data/tls`) | | `SMTPS_PORT` | порт неявного TLS (465); `0` — выключен | `0` | +| `IMAP_ENABLED`, `IMAP_PORT`, `IMAPS_PORT` | IMAP: включён; порт со STARTTLS; порт неявного TLS (`0` — выключен) | `true`, `3143`, `0` | +| `IMAP_AUTH_WITHOUT_TLS` | вход в IMAP без TLS — **только** в закрытой сети | `false` | | `SMTP_AUTH_WITHOUT_TLS` | разрешить `AUTH` без шифрования — **только** в закрытой сети (docker, ВМ) | `false` | | `OUTBOUND_MODE` | `off` / `mx` / `smarthost` | `off` | | `OUTBOUND_HOST`, `OUTBOUND_PORT`, `OUTBOUND_USERNAME`, `OUTBOUND_PASSWORD`, `OUTBOUND_TLS` | smarthost (`starttls` / `ssl` / `none`) | —, `587`, —, —, `starttls` | @@ -102,9 +115,8 @@ cd frontend && npm install && npx ng serve # http://loca ## Не сделано -- [ ] TLS для IMAP (вместе с переписыванием IMAP). SMTP — сделано: `STARTTLS` и порт 465. - [ ] Сертификат Let's Encrypt для настоящей почты (75.6): сейчас — свой файл или самоподписанный. -- [ ] IMAP выключен: упрощённый, блокирует сетевой поток (`.block()`). Переписать и проверить с Thunderbird. +- [ ] IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по `BODY`/`TEXT` без перебора писем. - [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново. - [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма. - [ ] Защита регистрации и входа от перебора (лимит попыток, капча). diff --git a/backend/src/main/java/com/mailserver/config/ImapConfig.java b/backend/src/main/java/com/mailserver/config/ImapConfig.java new file mode 100644 index 0000000..bc78abe --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/ImapConfig.java @@ -0,0 +1,59 @@ +package com.mailserver.config; + +import com.mailserver.imap.DbImapStore; +import com.mailserver.imap.ImapServer; +import com.mailserver.imap.ImapStore; +import com.mailserver.repository.AttachmentRepository; +import com.mailserver.repository.MessageRepository; +import com.mailserver.repository.UserRepository; +import com.mailserver.service.MailService; +import com.mailserver.service.MessageSources; +import com.mailserver.service.UserService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.util.unit.DataSize; + +import java.time.Duration; + +/** IMAP (75.2): STARTTLS on mailserver.imap.port, implicit TLS on mailserver.imaps.port when set. */ +@Configuration +public class ImapConfig { + + @Bean + public ImapStore imapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService, + MessageRepository messages, AttachmentRepository attachments, MessageSources sources, + @Value("${mailserver.smtp.hostname:localhost}") String hostname) { + return new DbImapStore(db, users, userService, mailService, messages, attachments, sources, hostname); + } + + @Bean(initMethod = "start", destroyMethod = "stop") + @ConditionalOnProperty(name = "mailserver.imap.enabled", havingValue = "true", matchIfMissing = true) + public ImapServer imapServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, + @Value("${mailserver.imap.host:0.0.0.0}") String host, + @Value("${mailserver.imap.port:3143}") int port, + @Value("${mailserver.smtp.hostname:localhost}") String hostname, + @Value("${mailserver.imap.auth-without-tls:false}") boolean authWithoutTls, + @Value("${mailserver.imap.max-literal:50MB}") DataSize maxLiteral, + @Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout, + @Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) { + return new ImapServer(new ImapServer.Settings(host, port, hostname, authWithoutTls, tls.context(), false, + (int) maxLiteral.toBytes(), idleTimeout, idlePoll), store); + } + + @Bean(initMethod = "start", destroyMethod = "stop") + @ConditionalOnExpression("'${mailserver.imap.enabled:true}' == 'true' and ${mailserver.imaps.port:0} > 0 and ${mailserver.tls.enabled:true}") + public ImapServer imapsServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, + @Value("${mailserver.imap.host:0.0.0.0}") String host, + @Value("${mailserver.imaps.port}") int port, + @Value("${mailserver.smtp.hostname:localhost}") String hostname, + @Value("${mailserver.imap.max-literal:50MB}") DataSize maxLiteral, + @Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout, + @Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) { + return new ImapServer(new ImapServer.Settings(host, port, hostname, false, tls.context(), true, + (int) maxLiteral.toBytes(), idleTimeout, idlePoll), store); + } +} diff --git a/backend/src/main/java/com/mailserver/config/ImapServerConfig.java b/backend/src/main/java/com/mailserver/config/ImapServerConfig.java deleted file mode 100644 index 3dd8dbf..0000000 --- a/backend/src/main/java/com/mailserver/config/ImapServerConfig.java +++ /dev/null @@ -1,580 +0,0 @@ -package com.mailserver.config; - -import com.mailserver.repository.FolderRepository; -import com.mailserver.repository.MessageRepository; -import com.mailserver.repository.UserRepository; -import com.mailserver.service.UserService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; - -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.PrintWriter; -import java.net.ServerSocket; -import java.net.Socket; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -/** - * Встроенный IMAP-сервер для почтового сервера. - * Поддерживает основные команды IMAP4rev1: LOGIN, SELECT, FETCH, SEARCH, STORE, EXPUNGE, LOGOUT. - * Запускается при старте приложения и останавливается при завершении. - */ -@Configuration -public class ImapServerConfig { - - private static final Logger log = LoggerFactory.getLogger(ImapServerConfig.class); - - @Value("${mailserver.imap.enabled:true}") - private boolean enabled; - - @Value("${mailserver.imap.port:143}") - private int port; - - @Value("${mailserver.imap.hostname:localhost}") - private String hostname; - - private final UserRepository userRepository; - private final FolderRepository folderRepository; - private final MessageRepository messageRepository; - private final UserService userService; - - private ServerSocket serverSocket; - private ExecutorService executorService; - - public ImapServerConfig(UserRepository userRepository, FolderRepository folderRepository, - MessageRepository messageRepository, UserService userService) { - this.userRepository = userRepository; - this.folderRepository = folderRepository; - this.messageRepository = messageRepository; - this.userService = userService; - } - - @PostConstruct - public void start() { - if (!enabled) { - log.info("IMAP-сервер отключён (mailserver.imap.enabled=false)"); - return; - } - - executorService = Executors.newCachedThreadPool(); - - try { - serverSocket = new ServerSocket(port); - log.info("IMAP-сервер запущен на порту {} (хост: {})", port, hostname); - - // Принимаем подключения в цикле - while (!serverSocket.isClosed()) { - try { - Socket clientSocket = serverSocket.accept(); - executorService.submit(() -> handleClient(clientSocket)); - } catch (IOException e) { - if (!serverSocket.isClosed()) { - log.error("Ошибка при принятии подключения", e); - } - } - } - } catch (IOException e) { - if (serverSocket == null || !serverSocket.isClosed()) { - log.error("Не удалось запустить IMAP-сервер на порту {}", port, e); - } - } - } - - @PreDestroy - public void stop() { - log.info("Остановка IMAP-сервера..."); - try { - if (serverSocket != null && !serverSocket.isClosed()) { - serverSocket.close(); - } - } catch (IOException e) { - log.error("Ошибка при остановке IMAP-сервера", e); - } - - if (executorService != null) { - executorService.shutdown(); - try { - if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) { - executorService.shutdownNow(); - } - } catch (InterruptedException e) { - executorService.shutdownNow(); - Thread.currentThread().interrupt(); - } - } - log.info("IMAP-сервер остановлен"); - } - - private void handleClient(Socket socket) { - String clientAddress = socket.getInetAddress().getHostAddress(); - log.info("Новое IMAP-подключение от {}", clientAddress); - - try (socket; - PrintWriter out = new PrintWriter(socket.getOutputStream(), true); - BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) { - - // Отправляем приветствие - out.println("* OK IMAP4rev1 Server Ready"); - - String username = null; - String line; - - while (username == null && (line = in.readLine()) != null) { - String[] parts = parseCommand(line); - if (parts == null) continue; - - String tag = parts[0]; - String command = parts[1].toUpperCase(); - - switch (command) { - case "LOGIN" -> { - if (parts.length >= 4) { - String email = parts[2].toLowerCase(); - String password = parts[3]; - - // Проверяем пароль через BCrypt - com.mailserver.domain.User user = userRepository.findByEmail(email).block(); - if (user != null && userService.checkPassword(user, password)) { - username = email; - out.println(tag + " OK LOGIN completed"); - log.info("Пользователь {} успешно авторизован через IMAP", email); - } else { - out.println(tag + " NO LOGIN failed: invalid credentials"); - log.warn("Неудачная попытка IMAP-авторизации для пользователя {}", email); - } - } else { - out.println(tag + " BAD Invalid LOGIN command"); - } - } - case "CAPABILITY" -> { - out.println("* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE LITERAL+"); - out.println(tag + " OK Capability completed"); - } - case "LOGOUT" -> { - out.println("* BYE IMAP4rev1 server terminating"); - out.println(tag + " OK LOGOUT completed"); - return; - } - default -> { - if (username == null) { - out.println(tag + " BAD Authentication required"); - } else { - handleAuthenticatedCommand(tag, command, parts, username, out, in); - } - } - } - } - } catch (IOException e) { - log.error("Ошибка при обработке IMAP-подключения от {}", clientAddress, e); - } finally { - log.info("IMAP-подключение от {} закрыто", clientAddress); - } - } - - private void handleAuthenticatedCommand(String tag, String command, String[] parts, - String username, PrintWriter out, BufferedReader in) { - switch (command) { - case "SELECT" -> handleSelect(tag, parts, username, out); - case "EXAMINE" -> handleSelect(tag, parts, username, out); // EXAMINE = SELECT в read-only режиме - case "FETCH" -> handleFetch(tag, parts, username, out); - case "SEARCH" -> handleSearch(tag, parts, username, out); - case "STORE" -> handleStore(tag, parts, username, out); - case "EXPUNGE" -> handleExpunge(tag, username, out); - case "LIST" -> handleList(tag, username, out); - case "LSUB" -> handleList(tag, username, out); // LSUB = LIST для подписок - case "STATUS" -> handleStatus(tag, parts, username, out); - case "ID" -> { - out.println("* ID (\"name\" \"MailServer IMAP\" \"version\" \"1.0\")"); - out.println(tag + " OK ID completed"); - } - default -> { - out.println(tag + " BAD Unknown command: " + command); - } - } - } - - private void handleSelect(String tag, String[] parts, String username, PrintWriter out) { - if (parts.length < 3) { - out.println(tag + " BAD Invalid SELECT command"); - return; - } - - String folderName = parts[2].replace("\"", ""); - com.mailserver.domain.User userTemp = userRepository.findByEmail(username).block(); - if (userTemp == null) { - out.println(tag + " NO User not found"); - return; - } - com.mailserver.domain.Folder folder = folderRepository.findByUserIdAndName( - userTemp.getId(), folderName).block(); - - if (folder == null) { - out.println(tag + " NO [NONEXISTENT] Mailbox does not exist"); - return; - } - - long messageCount = messageRepository.findByFolderId(folder.getId(), - org.springframework.data.domain.PageRequest.of(0, 1)).count().block(); - - out.println("* " + messageCount + " EXISTS"); - out.println("* 0 RECENT"); - out.println("* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)"); - out.println(tag + " [OK] [UNSEEN 1] " + (messageCount > 0 ? "There are unseen messages in the mailbox." : "The mailbox has no unseen messages.")); - out.println(tag + " OK [READ-WRITE] SELECT completed"); - } - - private void handleFetch(String tag, String[] parts, String username, PrintWriter out) { - if (parts.length < 4) { - out.println(tag + " BAD Invalid FETCH command"); - return; - } - - String messageId = parts[2]; - String item = parts[3]; - - com.mailserver.domain.User user = userRepository.findByEmail(username).block(); - if (user == null) { - out.println(tag + " NO User not found"); - return; - } - - // Получаем все сообщения пользователя - java.util.List folders = folderRepository.findByUserId(user.getId()).collectList().block(); - java.util.List allMessages = new java.util.ArrayList<>(); - for (com.mailserver.domain.Folder folder : folders) { - allMessages.addAll(messageRepository.findByFolderId(folder.getId(), - org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block()); - } - - // Находим сообщение по UID или индексу - com.mailserver.domain.Message message = null; - if (item.contains("UID")) { - // UID FETCH - try { - long uid = Long.parseLong(messageId); - for (com.mailserver.domain.Message msg : allMessages) { - if (msg.getId().equals(uid)) { - message = msg; - break; - } - } - } catch (NumberFormatException e) { - out.println(tag + " BAD Invalid UID"); - return; - } - } else { - // FETCH по индексу (1-based) - try { - int index = Integer.parseInt(messageId) - 1; - if (index >= 0 && index < allMessages.size()) { - message = allMessages.get(index); - } - } catch (NumberFormatException e) { - out.println(tag + " BAD Invalid message set"); - return; - } - } - - if (message == null) { - out.println(tag + " NO Message not found"); - return; - } - - // Формируем ответ FETCH - StringBuilder flags = new StringBuilder(); - if (message.isRead()) flags.append("\\Seen "); - if (message.isFlagged()) flags.append("\\Flagged "); - if (message.isSpam()) flags.append("\\Deleted "); - if (message.getBody() != null && !message.getBody().isEmpty()) flags.append("\\Answered "); - - out.println("* " + messageId + " FETCH ("); - out.println(" UID " + message.getId()); - out.println(" FLAGS (" + flags.toString().trim() + ")"); - out.println(" INTERNALDATE \"" + formatImapDate(message.getReceivedAt()) + "\""); - out.println(" RFC822.SIZE " + (message.getMessageSize() != null ? message.getMessageSize() : 0)); - out.println(" ENVELOPE ("); - out.println(" \"" + escapeString(message.getReceivedAt() != null ? message.getReceivedAt().toString() : "") + "\""); - out.println(" \"" + escapeString(message.getSubject() != null ? message.getSubject() : "") + "\""); - out.println(" (\"" + escapeString(message.getSenderName() != null ? message.getSenderName() : "") + "\" 0 \"\" \"" + escapeString(message.getSenderEmail() != null ? message.getSenderEmail() : "") + "\")"); - out.println(" (\"" + escapeString(message.getRecipientEmail() != null ? message.getRecipientEmail() : "") + "\")"); - out.println(" (\"" + escapeString(message.getRecipientEmail() != null ? message.getRecipientEmail() : "") + "\")"); - out.println(" (\"" + escapeString(message.getSenderEmail() != null ? message.getSenderEmail() : "") + "\")"); - out.println(" NIL"); - out.println(" NIL"); - out.println(" NIL"); - out.println(" )"); - - // Если запрошено тело сообщения - if (item.contains("BODY[]") || item.contains("RFC822")) { - out.println(" BODY[] {"); - String body = message.getBody() != null ? message.getBody() : ""; - out.println(body.length()); - out.println(body); - out.println(")"); - } - - out.println(")"); - out.println(tag + " OK FETCH completed"); - } - - private void handleSearch(String tag, String[] parts, String username, PrintWriter out) { - com.mailserver.domain.User user = userRepository.findByEmail(username).block(); - if (user == null) { - out.println(tag + " NO User not found"); - return; - } - - com.mailserver.domain.Folder inbox = folderRepository.findByUserIdAndName(user.getId(), "INBOX").block(); - if (inbox == null) { - out.println(tag + " OK SEARCH completed"); - return; - } - - // Простой поиск по ключевому слову - String keyword = null; - for (int i = 2; i < parts.length; i++) { - if (!parts[i].equals("ALL") && !parts[i].equals("FROM") && !parts[i].equals("SUBJECT") && !parts[i].equals("BODY")) { - keyword = parts[i]; - break; - } - } - - java.util.List messageIds; - if (keyword != null && !keyword.isEmpty()) { - var results = messageRepository.searchInFolder(inbox.getId(), keyword, - org.springframework.data.domain.PageRequest.of(0, 100)).collectList().block(); - messageIds = results.stream() - .map(com.mailserver.domain.Message::getId) - .collect(java.util.stream.Collectors.toList()); - } else { - var results = messageRepository.findByFolderId(inbox.getId(), - org.springframework.data.domain.PageRequest.of(0, 100)).collectList().block(); - messageIds = results.stream() - .map(com.mailserver.domain.Message::getId) - .collect(java.util.stream.Collectors.toList()); - } - - StringBuilder ids = new StringBuilder(); - for (Long id : messageIds) { - ids.append(id).append(" "); - } - - out.println("* SEARCH " + ids.toString().trim()); - out.println(tag + " OK SEARCH completed"); - } - - private void handleStore(String tag, String[] parts, String username, PrintWriter out) { - if (parts.length < 5) { - out.println(tag + " BAD Invalid STORE command"); - return; - } - - String messageId = parts[2]; - String item = parts[3].toUpperCase(); - String value = parts[4].toUpperCase(); - - com.mailserver.domain.User user = userRepository.findByEmail(username).block(); - if (user == null) { - out.println(tag + " NO User not found"); - return; - } - - // Получаем все сообщения пользователя - java.util.List folders = folderRepository.findByUserId(user.getId()).collectList().block(); - java.util.List allMessages = new java.util.ArrayList<>(); - for (com.mailserver.domain.Folder folder : folders) { - allMessages.addAll(messageRepository.findByFolderId(folder.getId(), - org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block()); - } - - com.mailserver.domain.Message message = null; - try { - int index = Integer.parseInt(messageId) - 1; - if (index >= 0 && index < allMessages.size()) { - message = allMessages.get(index); - } - } catch (NumberFormatException e) { - out.println(tag + " BAD Invalid message set"); - return; - } - - if (message == null) { - out.println(tag + " NO Message not found"); - return; - } - - boolean saved = false; - if (item.contains("+FLAGS") || item.contains("FLAGS")) { - if (value.contains("\\SEEN")) { - message.setRead(true); - saved = true; - } - if (value.contains("\\FLAGGED")) { - message.setFlagged(true); - saved = true; - } - if (value.contains("\\DELETED")) { - message.setSpam(true); // Используем spam как флаг удаления для IMAP - saved = true; - } - } - - if (item.contains("-FLAGS")) { - if (value.contains("\\SEEN")) { - message.setRead(false); - saved = true; - } - if (value.contains("\\FLAGGED")) { - message.setFlagged(false); - saved = true; - } - } - - if (saved) { - messageRepository.save(message).block(); - } - - out.println("* " + messageId + " FETCH (FLAGS (" + formatFlags(message) + "))"); - out.println(tag + " OK STORE completed"); - } - - private void handleExpunge(String tag, String username, PrintWriter out) { - com.mailserver.domain.User user = userRepository.findByEmail(username).block(); - if (user == null) { - out.println(tag + " NO User not found"); - return; - } - - java.util.List folders = folderRepository.findByUserId(user.getId()).collectList().block(); - for (com.mailserver.domain.Folder folder : folders) { - var messages = messageRepository.findByFolderId(folder.getId(), - org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block(); - for (com.mailserver.domain.Message message : messages) { - if (message.isSpam()) { - messageRepository.delete(message).block(); - folder.setMessageCount(Math.max(0, folder.getMessageCount() - 1)); - folderRepository.save(folder).block(); - } - } - } - - out.println("* 0 EXPUNGE"); - out.println(tag + " OK EXPUNGE completed"); - } - - private void handleList(String tag, String username, PrintWriter out) { - com.mailserver.domain.User user = userRepository.findByEmail(username).block(); - if (user == null) { - out.println(tag + " NO User not found"); - return; - } - - java.util.List folders = folderRepository.findByUserId(user.getId()).collectList().block(); - for (com.mailserver.domain.Folder folder : folders) { - out.println("* LIST (\\HasNoChildren) \".\" \"" + folder.getName() + "\""); - } - - out.println(tag + " OK LIST completed"); - } - - private void handleStatus(String tag, String[] parts, String username, PrintWriter out) { - if (parts.length < 4) { - out.println(tag + " BAD Invalid STATUS command"); - return; - } - - String folderName = parts[2].replace("\"", ""); - com.mailserver.domain.User user = userRepository.findByEmail(username).block(); - if (user == null) { - out.println(tag + " NO User not found"); - return; - } - - com.mailserver.domain.Folder folder = folderRepository.findByUserIdAndName(user.getId(), folderName).block(); - if (folder == null) { - out.println(tag + " NO [NONEXISTENT] Mailbox does not exist"); - return; - } - - long messageCount = messageRepository.findByFolderId(folder.getId(), - org.springframework.data.domain.PageRequest.of(0, 1)).count().block(); - long unreadCount = messageRepository.countUnreadByFolderId(folder.getId()).block(); - - out.println("* STATUS " + folderName + " (MESSAGES " + messageCount + " UNSEEN " + unreadCount + " UIDNEXT " + (messageCount + 1) + " UIDVALIDITY 1)"); - out.println(tag + " OK STATUS completed"); - } - - private String[] parseCommand(String line) { - if (line == null || line.trim().isEmpty()) return null; - - line = line.trim(); - int spaceIndex = line.indexOf(' '); - if (spaceIndex == -1) return new String[]{line, ""}; - - String tag = line.substring(0, spaceIndex); - String rest = line.substring(spaceIndex + 1).trim(); - - // Разбираем команду и аргументы - String[] parts = new String[20]; // Максимум 20 аргументов - parts[0] = tag; - int index = 1; - - int i = 0; - while (i < rest.length() && index < parts.length) { - if (rest.charAt(i) == '"') { - // Строка в кавычках - int end = rest.indexOf('"', i + 1); - if (end == -1) break; - parts[index++] = rest.substring(i + 1, end); - i = end + 1; - } else if (Character.isWhitespace(rest.charAt(i))) { - i++; - } else { - int end = rest.indexOf(' ', i); - if (end == -1) end = rest.length(); - parts[index++] = rest.substring(i, end); - i = end; - } - } - - // Обрезаем массив до нужного размера - String[] result = new String[index]; - System.arraycopy(parts, 0, result, 0, index); - return result; - } - - private String formatImapDate(Instant instant) { - if (instant == null) return ""; - // Формат IMAP: "dd-MMM-yyyy hh:mm:ss +0000" - String[] months = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; - java.time.LocalDate date = instant.atZone(java.time.ZoneOffset.UTC).toLocalDate(); - java.time.LocalTime time = instant.atZone(java.time.ZoneOffset.UTC).toLocalTime(); - - return String.format("%02d-%s-%04d %02d:%02d:%02d +0000", - date.getDayOfMonth(), months[date.getMonthValue() - 1], date.getYear(), - time.getHour(), time.getMinute(), time.getSecond()); - } - - private String escapeString(String str) { - if (str == null) return ""; - return str.replace("\\", "\\\\").replace("\"", "\\\""); - } - - private String formatFlags(com.mailserver.domain.Message message) { - StringBuilder flags = new StringBuilder(); - if (message.isRead()) flags.append("\\Seen "); - if (message.isFlagged()) flags.append("\\Flagged "); - if (message.isSpam()) flags.append("\\Deleted "); - return flags.toString().trim(); - } -} diff --git a/backend/src/main/java/com/mailserver/imap/DbImapStore.java b/backend/src/main/java/com/mailserver/imap/DbImapStore.java new file mode 100644 index 0000000..7973a3d --- /dev/null +++ b/backend/src/main/java/com/mailserver/imap/DbImapStore.java @@ -0,0 +1,285 @@ +package com.mailserver.imap; + +import com.mailserver.domain.Attachment; +import com.mailserver.domain.Message; +import com.mailserver.domain.User; +import com.mailserver.repository.AttachmentRepository; +import com.mailserver.repository.MessageRepository; +import com.mailserver.repository.UserRepository; +import com.mailserver.service.LocalDelivery; +import com.mailserver.service.MailService; +import com.mailserver.service.MessageSources; +import com.mailserver.service.UserService; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.web.server.ResponseStatusException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import java.util.UUID; + +/** {@link ImapStore} over the mail server's PostgreSQL tables (the same data the web client shows). */ +public class DbImapStore implements ImapStore { + + private static final Logger log = LoggerFactory.getLogger(DbImapStore.class); + + private final DatabaseClient db; + private final UserRepository users; + private final UserService userService; + private final MailService mailService; + private final MessageRepository messages; + private final AttachmentRepository attachments; + private final MessageSources sources; + private final String hostname; + + public DbImapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService, + MessageRepository messages, AttachmentRepository attachments, MessageSources sources, + String hostname) { + this.db = db; + this.users = users; + this.userService = userService; + this.mailService = mailService; + this.messages = messages; + this.attachments = attachments; + this.sources = sources; + this.hostname = hostname; + } + + @Override + public Account authenticate(String username, String password) { + User user = users.findByEmail(username.trim().toLowerCase(Locale.ROOT)).block(); + if (user == null || !user.isActive() || !userService.checkPassword(user, password)) { + return null; + } + return new Account(user.getId(), user.getEmail()); + } + + @Override + public List mailboxes(long userId) { + return db.sql("SELECT id, name, is_system, uid_validity, uid_next FROM folders WHERE user_id = :u ORDER BY id") + .bind("u", userId) + .map(row -> new Mailbox(row.get("id", Long.class), row.get("name", String.class), + Boolean.TRUE.equals(row.get("is_system", Boolean.class)), + row.get("uid_validity", Long.class), row.get("uid_next", Long.class))) + .all().collectList().block(); + } + + @Override + public Mailbox create(long userId, String name) { + try { + var created = mailService.createFolder(userId, name).block(); + return mailboxes(userId).stream().filter(m -> m.id() == created.id()).findFirst().orElseThrow(); + } catch (ResponseStatusException e) { + throw new Refused(e.getReason()); + } + } + + @Override + public void rename(long userId, Mailbox mailbox, String newName) { + try { + mailService.renameFolder(mailbox.id(), userId, newName).block(); + } catch (ResponseStatusException e) { + throw new Refused(e.getReason()); + } + } + + @Override + public void delete(long userId, Mailbox mailbox) { + try { + mailService.deleteFolder(mailbox.id(), userId).block(); + } catch (ResponseStatusException e) { + throw new Refused(e.getReason()); + } + } + + @Override + public List entries(long folderId) { + return db.sql("SELECT id, uid, is_read, is_flagged, is_answered, is_deleted, received_at FROM messages " + + "WHERE folder_id = :f ORDER BY uid") + .bind("f", folderId) + .map(row -> new Entry(row.get("id", Long.class), row.get("uid", Long.class), + Boolean.TRUE.equals(row.get("is_read", Boolean.class)), + Boolean.TRUE.equals(row.get("is_flagged", Boolean.class)), + Boolean.TRUE.equals(row.get("is_answered", Boolean.class)), + Boolean.TRUE.equals(row.get("is_deleted", Boolean.class)), + instant(row.get("received_at")))) + .all().collectList().block(); + } + + @Override + public byte[] source(long messageId) { + byte[] raw = sources.get(messageId).block(); + if (raw != null) { + return raw; + } + Message message = messages.findById(messageId).block(); + if (message == null) { + return new byte[0]; + } + try { + List files = attachments.findByMessageId(messageId).collectList().block(); + raw = MimeSource.build(message, files, hostname); + } catch (Exception e) { + log.warn("IMAP: cannot build message {}: {}", messageId, e.toString()); + return ("Subject: " + message.getSubject() + "\r\n\r\n" + message.getBody()).getBytes(); + } + sources.put(messageId, raw).block(); + return raw; + } + + @Override + public void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted) { + db.sql("UPDATE messages SET is_read = :s, is_flagged = :f, is_answered = :a, is_deleted = :d WHERE id = :id") + .bind("s", seen).bind("f", flagged).bind("a", answered).bind("d", deleted).bind("id", messageId) + .then().block(); + } + + @Override + public long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged, boolean answered, + boolean deleted, Instant internalDate) { + try { + MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw)); + Message message = new Message(); + message.setFolderId(target.id()); + String senderEmail = "unknown@" + hostname; + String senderName = null; + if (mime.getFrom() != null && mime.getFrom().length > 0 && mime.getFrom()[0] instanceof InternetAddress from) { + senderEmail = from.getAddress(); + senderName = from.getPersonal(); + } + message.setSenderEmail(senderEmail); + message.setSenderName(senderName); + String to = LocalDelivery.addresses(mime, jakarta.mail.Message.RecipientType.TO); + message.setRecipientEmail(to == null ? "" : to); + message.setCcEmail(LocalDelivery.addresses(mime, jakarta.mail.Message.RecipientType.CC)); + message.setSubject(mime.getSubject() == null ? "" : mime.getSubject()); + LocalDelivery.Body body = LocalDelivery.body(mime); + message.setBody(body.text()); + message.setHtml(body.html()); + message.setRead(seen); + message.setFlagged(flagged); + message.setSpam(target.system() && "Spam".equals(target.name())); + message.setReceivedAt(internalDate == null ? Instant.now() : internalDate); + String messageId = mime.getMessageID(); + message.setMessageId(messageId == null ? UUID.randomUUID().toString() : messageId.replaceAll("^<|>$", "")); + message.setMessageSize((long) raw.length); + Message stored = messages.save(message).block(); + for (LocalDelivery.FilePart file : LocalDelivery.attachments(mime)) { + mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes()); + } + sources.put(stored.getId(), raw).block(); + setFlags(stored.getId(), seen, flagged, answered, deleted); + return uid(stored.getId()); + } catch (Refused e) { + throw e; + } catch (Exception e) { + log.warn("IMAP APPEND to {} failed: {}", target.name(), e.toString()); + throw new Refused("Cannot store the message: " + e.getMessage()); + } + } + + @Override + public List copy(long userId, List list, Mailbox target) { + boolean spam = target.system() && "Spam".equals(target.name()); + List uids = new ArrayList<>(); + for (Entry entry : list) { + Long newId = db.sql("INSERT INTO messages (folder_id, sender_email, sender_name, recipient_email, cc_email, " + + "subject, body, is_html, is_read, is_flagged, is_spam, is_answered, is_deleted, message_size, " + + "received_at, message_id) " + + "SELECT :t, sender_email, sender_name, recipient_email, cc_email, subject, body, is_html, " + + "is_read, is_flagged, :spam, is_answered, is_deleted, message_size, received_at, message_id " + + "FROM messages WHERE id = :id RETURNING id") + .bind("t", target.id()).bind("spam", spam).bind("id", entry.id()) + .map(row -> row.get("id", Long.class)).one().block(); + if (newId == null) { + continue; + } + for (Attachment attachment : attachments.findByMessageId(entry.id()).collectList().block()) { + copyAttachment(attachment, newId); + } + db.sql("INSERT INTO message_sources (message_id, raw) SELECT :n, raw FROM message_sources WHERE message_id = :o") + .bind("n", newId).bind("o", entry.id()).then().block(); + uids.add(uid(newId)); + } + return uids; + } + + private void copyAttachment(Attachment attachment, long newMessageId) { + try { + Path from = Paths.get(attachment.getFilePath()); + Path to = from.resolveSibling(UUID.randomUUID() + "_" + from.getFileName().toString().replaceFirst("^[0-9a-f-]{36}_", "")); + Files.copy(from, to); + Attachment copy = new Attachment(); + copy.setMessageId(newMessageId); + copy.setFilename(attachment.getFilename()); + copy.setContentType(attachment.getContentType()); + copy.setSizeBytes(attachment.getSizeBytes()); + copy.setFilePath(to.toString()); + copy.setContentId(attachment.getContentId()); + attachments.save(copy).block(); + } catch (IOException e) { + log.warn("IMAP COPY: attachment {} not copied: {}", attachment.getFilePath(), e.toString()); + } + } + + @Override + public List move(long userId, List list, Mailbox target) { + boolean spam = target.system() && "Spam".equals(target.name()); + List uids = new ArrayList<>(); + for (Entry entry : list) { + Long uid = db.sql("UPDATE messages SET folder_id = :t, is_spam = :spam WHERE id = :id RETURNING uid") + .bind("t", target.id()).bind("spam", spam).bind("id", entry.id()) + .map(row -> row.get("uid", Long.class)).one().block(); + if (uid != null) { + uids.add(uid); + } + } + return uids; + } + + @Override + public void expunge(List messageIds) { + for (Long id : messageIds) { + for (Attachment attachment : attachments.findByMessageId(id).collectList().block()) { + try { + Files.deleteIfExists(Paths.get(attachment.getFilePath())); + } catch (IOException e) { + log.warn("Attachment file {} not deleted: {}", attachment.getFilePath(), e.getMessage()); + } + } + messages.deleteById(id).block(); + } + } + + private long uid(long messageId) { + return db.sql("SELECT uid FROM messages WHERE id = :id").bind("id", messageId) + .map(row -> row.get("uid", Long.class)).one().block(); + } + + private static Instant instant(Object value) { + if (value instanceof Instant instant) { + return instant; + } + if (value instanceof LocalDateTime local) { + return local.toInstant(ZoneOffset.UTC); + } + if (value instanceof java.time.OffsetDateTime offset) { + return offset.toInstant(); + } + return Instant.EPOCH; + } +} diff --git a/backend/src/main/java/com/mailserver/imap/ImapReader.java b/backend/src/main/java/com/mailserver/imap/ImapReader.java new file mode 100644 index 0000000..bc569cb --- /dev/null +++ b/backend/src/main/java/com/mailserver/imap/ImapReader.java @@ -0,0 +1,194 @@ +package com.mailserver.imap; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Reads one IMAP command: the decoder has already collected it whole, with its literals inline + * ("{n}\r\n" + n bytes, or "{n+}\r\n" + n bytes). + */ +final class ImapReader { + + static final class SyntaxError extends RuntimeException { + SyntaxError(String message) { + super(message); + } + } + + private final byte[] b; + private int pos; + + ImapReader(byte[] command) { + this(command, true); + } + + private ImapReader(byte[] command, boolean stripLineEnd) { + int end = command.length; + if (!stripLineEnd) { + this.b = command; + return; + } + if (end > 0 && command[end - 1] == '\n') { + end--; + } + if (end > 0 && command[end - 1] == '\r') { + end--; + } + this.b = java.util.Arrays.copyOf(command, end); + } + + boolean atEnd() { + return pos >= b.length; + } + + int peek() { + return pos < b.length ? b[pos] & 0xff : -1; + } + + void sp() { + if (peek() != ' ') { + throw new SyntaxError("Expected a space"); + } + while (peek() == ' ') { + pos++; + } + } + + boolean optionalSp() { + if (peek() == ' ') { + sp(); + return true; + } + return false; + } + + void expect(char c) { + if (peek() != c) { + throw new SyntaxError("Expected '" + c + "'"); + } + pos++; + } + + /** Characters up to a space, parenthesis, or the end (an atom; also tags, sequence sets, flags). */ + String atom() { + int start = pos; + while (pos < b.length) { + int c = b[pos] & 0xff; + if (c == ' ' || c == '(' || c == ')' || c == '{' || c == '"' || c < 0x20 || c == 0x7f) { + break; + } + pos++; + } + if (pos == start) { + throw new SyntaxError("Expected an atom"); + } + return new String(b, start, pos - start, StandardCharsets.UTF_8); + } + + /** Atom, quoted string or literal. */ + String astring() { + int c = peek(); + if (c == '"' || c == '{') { + return new String(stringBytes(), StandardCharsets.UTF_8); + } + return atom(); + } + + /** Quoted string or literal, raw bytes. */ + byte[] stringBytes() { + int c = peek(); + if (c == '"') { + pos++; + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + while (true) { + if (pos >= b.length) { + throw new SyntaxError("Unterminated quoted string"); + } + byte x = b[pos++]; + if (x == '"') { + return out.toByteArray(); + } + if (x == '\\' && pos < b.length) { + x = b[pos++]; + } + out.write(x); + } + } + if (c == '{') { + int close = indexOf('}', pos); + if (close < 0) { + throw new SyntaxError("Bad literal"); + } + String size = new String(b, pos + 1, close - pos - 1, StandardCharsets.US_ASCII); + if (size.endsWith("+")) { + size = size.substring(0, size.length() - 1); + } + int length; + try { + length = Integer.parseInt(size); + } catch (NumberFormatException e) { + throw new SyntaxError("Bad literal size"); + } + pos = close + 1; + if (peek() == '\r') { + pos++; + } + expect('\n'); + if (pos + length > b.length) { + throw new SyntaxError("Literal shorter than announced"); + } + byte[] data = java.util.Arrays.copyOfRange(b, pos, pos + length); + pos += length; + return data; + } + throw new SyntaxError("Expected a string"); + } + + /** "(a b c)" → [a, b, c] (atoms or strings); an atom without parentheses → [atom]. */ + List list() { + List items = new ArrayList<>(); + if (peek() != '(') { + items.add(astring()); + return items; + } + pos++; + while (peek() == ' ') { + pos++; + } + while (peek() != ')') { + if (atEnd()) { + throw new SyntaxError("Unterminated list"); + } + items.add(astring()); + while (peek() == ' ') { + pos++; + } + } + pos++; + return items; + } + + /** Everything left, as text (used for FETCH attributes and SEARCH keys parsed by their own readers). */ + String rest() { + String text = new String(b, pos, b.length - pos, StandardCharsets.UTF_8); + pos = b.length; + return text; + } + + /** A reader over the rest, as is (SEARCH may hold literals). */ + ImapReader restReader() { + ImapReader reader = new ImapReader(java.util.Arrays.copyOfRange(b, pos, b.length), false); + pos = b.length; + return reader; + } + + private int indexOf(char c, int from) { + for (int i = from; i < b.length; i++) { + if (b[i] == c) { + return i; + } + } + return -1; + } +} diff --git a/backend/src/main/java/com/mailserver/imap/ImapServer.java b/backend/src/main/java/com/mailserver/imap/ImapServer.java new file mode 100644 index 0000000..36a0564 --- /dev/null +++ b/backend/src/main/java/com/mailserver/imap/ImapServer.java @@ -0,0 +1,260 @@ +package com.mailserver.imap; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPipeline; +import io.netty.handler.codec.ByteToMessageDecoder; +import io.netty.handler.codec.TooLongFrameException; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslHandler; +import io.netty.handler.timeout.ReadTimeoutHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.scheduler.Schedulers; +import reactor.netty.Connection; +import reactor.netty.DisposableServer; +import reactor.netty.NettyOutbound; +import reactor.netty.tcp.TcpServer; + +import java.io.ByteArrayOutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * IMAP server (RFC 3501) on Reactor Netty. + *
    + *
  • {@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 DisposableServer server; + + public ImapServer(Settings settings, ImapStore store) { + this.settings = settings; + this.store = store; + } + + 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, 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())); + }) + .takeUntil(response -> session.closing); + + 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 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 out) { + while (in.isReadable()) { + if (literalLeft > 0) { + int n = Math.min(literalLeft, in.readableBytes()); + byte[] chunk = new byte[n]; + in.readBytes(chunk); + command.write(chunk, 0, n); + literalLeft -= n; + continue; + } + int newline = in.indexOf(in.readerIndex(), in.writerIndex(), (byte) '\n'); + if (newline < 0) { + if (lineBytes + in.readableBytes() > MAX_LINE) { + throw new TooLongFrameException("Command line too long"); + } + return; + } + int length = newline - in.readerIndex() + 1; + if (lineBytes + length > MAX_LINE) { + throw new TooLongFrameException("Command line too long"); + } + byte[] line = new byte[length]; + in.readBytes(line); + command.write(line, 0, line.length); + lineBytes += length; + Matcher literal = LITERAL.matcher(new String(line, StandardCharsets.ISO_8859_1)); + if (literal.find()) { + long size = Long.parseLong(literal.group(1)); + if (size > maxLiteral) { + ctx.writeAndFlush(Unpooled.copiedBuffer("* BYE Literal too large\r\n", StandardCharsets.US_ASCII)); + throw new TooLongFrameException("Literal too large: " + size); + } + literalLeft = (int) size; + if (literal.group(2).isEmpty()) { + ctx.writeAndFlush(Unpooled.copiedBuffer("+ Ready for literal data\r\n", StandardCharsets.US_ASCII)); + } + continue; + } + out.add(new Command(command.toByteArray(), ctx.pipeline().get(SslHandler.class) != null)); + command.reset(); + lineBytes = 0; + } + } + } +} diff --git a/backend/src/main/java/com/mailserver/imap/ImapSession.java b/backend/src/main/java/com/mailserver/imap/ImapSession.java new file mode 100644 index 0000000..8c5ee7b --- /dev/null +++ b/backend/src/main/java/com/mailserver/imap/ImapSession.java @@ -0,0 +1,1246 @@ +package com.mailserver.imap; + +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.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; + 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; + 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 = settings; + this.store = store; + 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; + } + loggedIn(tag, store.authenticate(user, password), user, 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; + } + loggedIn(tag, store.authenticate(fields[1], fields[2]), fields[1], out); + } + + 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 > 5) { + 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); + } +} diff --git a/backend/src/main/java/com/mailserver/imap/ImapStore.java b/backend/src/main/java/com/mailserver/imap/ImapStore.java new file mode 100644 index 0000000..11264fe --- /dev/null +++ b/backend/src/main/java/com/mailserver/imap/ImapStore.java @@ -0,0 +1,91 @@ +package com.mailserver.imap; + +import java.time.Instant; +import java.util.List; +import java.util.Locale; + +/** + * Mailboxes and messages as IMAP sees them. Blocking: the IMAP server calls it from a worker thread, + * never from the network event loop. + */ +public interface ImapStore { + + /** A refusal the client sees as "NO reason". */ + final class Refused extends RuntimeException { + public Refused(String reason) { + super(reason); + } + } + + record Account(long userId, String email) { + } + + record Mailbox(long id, String name, boolean system, long uidValidity, long uidNext) { + + boolean isInbox() { + return system && "INBOX".equalsIgnoreCase(name); + } + + boolean isDrafts() { + return system && "Drafts".equals(name); + } + + /** RFC 6154 attribute, or null. */ + String specialUse() { + if (!system) { + return null; + } + return switch (name) { + case "Drafts" -> "\\Drafts"; + case "Sent" -> "\\Sent"; + case "Spam" -> "\\Junk"; + case "Trash" -> "\\Trash"; + default -> null; + }; + } + + boolean named(String other) { + return isInbox() ? "INBOX".equalsIgnoreCase(other) : name.toLowerCase(Locale.ROOT).equals(other.toLowerCase(Locale.ROOT)); + } + } + + record Entry(long id, long uid, boolean seen, boolean flagged, boolean answered, boolean deleted, Instant internalDate) { + + Entry withFlags(boolean seen, boolean flagged, boolean answered, boolean deleted) { + return new Entry(id, uid, seen, flagged, answered, deleted, internalDate); + } + } + + /** null — wrong user or password. */ + Account authenticate(String username, String password); + + List mailboxes(long userId); + + Mailbox create(long userId, String name); + + void rename(long userId, Mailbox mailbox, String newName); + + /** An own folder: its messages go to the Trash (as in the web client), then the folder is removed. */ + void delete(long userId, Mailbox mailbox); + + /** Messages of the folder in UID order. */ + List entries(long folderId); + + /** The full MIME text of the message (the original one, or built once and kept). */ + byte[] source(long messageId); + + void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted); + + /** Stores the message into the folder; returns its UID. */ + long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged, boolean answered, + boolean deleted, Instant internalDate); + + /** Copies; returns the new UIDs in the same order. */ + List copy(long userId, List messages, Mailbox target); + + /** Moves; returns the new UIDs in the same order. */ + List move(long userId, List messages, Mailbox target); + + /** Removes the messages for good (with attachment files). */ + void expunge(List messageIds); +} diff --git a/backend/src/main/java/com/mailserver/imap/ImapWriter.java b/backend/src/main/java/com/mailserver/imap/ImapWriter.java new file mode 100644 index 0000000..d77a2a5 --- /dev/null +++ b/backend/src/main/java/com/mailserver/imap/ImapWriter.java @@ -0,0 +1,255 @@ +package com.mailserver.imap; + +import jakarta.mail.internet.AddressException; +import jakarta.mail.internet.ContentDisposition; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeUtility; +import jakarta.mail.internet.ParameterList; +import jakarta.mail.internet.ParseException; + +import java.io.ByteArrayOutputStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.Enumeration; +import java.util.Locale; + +/** Builds IMAP response bytes: strings, literals, ENVELOPE, BODYSTRUCTURE (RFC 3501 7.4.2). */ +final class ImapWriter { + + private final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + ImapWriter text(String text) { + byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + out.write(bytes, 0, bytes.length); + return this; + } + + ImapWriter sp() { + out.write(' '); + return this; + } + + ImapWriter crlf() { + return text("\r\n"); + } + + ImapWriter literal(byte[] bytes) { + text("{" + bytes.length + "}\r\n"); + out.write(bytes, 0, bytes.length); + return this; + } + + /** Quoted when it is plain ASCII without CR/LF, a literal otherwise; null → NIL. */ + ImapWriter nstring(String value) { + if (value == null) { + return text("NIL"); + } + return string(value); + } + + ImapWriter string(String value) { + boolean quotable = value.length() < 1024; + for (int i = 0; i < value.length() && quotable; i++) { + char c = value.charAt(i); + if (c > 0x7e || c < 0x20) { + quotable = false; + } + } + if (!quotable) { + return literal(value.getBytes(StandardCharsets.UTF_8)); + } + return text("\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""); + } + + byte[] bytes() { + return out.toByteArray(); + } + + // ---------- ENVELOPE ---------- + + ImapWriter envelope(MimeTree message) { + text("("); + nstring(message.header("Date")).sp(); + nstring(message.header("Subject")).sp(); + String from = message.header("From"); + addresses(from).sp(); + addresses(firstNonNull(message.header("Sender"), from)).sp(); + addresses(firstNonNull(message.header("Reply-To"), from)).sp(); + addresses(message.header("To")).sp(); + addresses(message.header("Cc")).sp(); + addresses(message.header("Bcc")).sp(); + nstring(message.header("In-Reply-To")).sp(); + nstring(message.header("Message-ID")); + return text(")"); + } + + private ImapWriter addresses(String header) { + if (header == null || header.isBlank()) { + return text("NIL"); + } + InternetAddress[] list; + try { + list = InternetAddress.parseHeader(header, false); + } catch (AddressException e) { + return text("NIL"); + } + if (list.length == 0) { + return text("NIL"); + } + text("("); + for (InternetAddress address : list) { + String email = address.getAddress() == null ? "" : address.getAddress(); + int at = email.lastIndexOf('@'); + text("("); + nstring(encodedPersonal(address.getPersonal())).sp().text("NIL").sp(); + nstring(at > 0 ? email.substring(0, at) : email).sp(); + nstring(at > 0 ? email.substring(at + 1) : null); + text(")"); + } + return text(")"); + } + + private static String encodedPersonal(String personal) { + if (personal == null) { + return null; + } + try { + return MimeUtility.encodeWord(personal, "UTF-8", "B"); + } catch (UnsupportedEncodingException e) { + return personal; + } + } + + // ---------- BODYSTRUCTURE / BODY ---------- + + ImapWriter bodyStructure(MimeTree part, boolean extensible) { + text("("); + if (part.isMultipart() && !part.children.isEmpty()) { + for (MimeTree child : part.children) { + bodyStructure(child, extensible); + } + sp().string(part.type.getSubType().toUpperCase(Locale.ROOT)); + if (extensible) { + sp().params(part.type.getParameterList()).sp(); + disposition(part).sp().text("NIL NIL"); + } + return text(")"); + } + String primary = part.isMultipart() ? "TEXT" : part.type.getPrimaryType().toUpperCase(Locale.ROOT); + String sub = part.isMultipart() ? "PLAIN" : part.type.getSubType().toUpperCase(Locale.ROOT); + string(primary).sp().string(sub).sp(); + params(part.type.getParameterList()).sp(); + nstring(part.header("Content-ID")).sp(); + nstring(part.header("Content-Description")).sp(); + String encoding = part.header("Content-Transfer-Encoding"); + string(encoding == null ? "7BIT" : encoding.toUpperCase(Locale.ROOT)).sp(); + text(String.valueOf(part.bodySize())); + if ("TEXT".equals(primary)) { + sp().text(String.valueOf(part.bodyLines())); + } else if (part.message != null) { + sp().envelope(part.message).sp().bodyStructure(part.message, extensible).sp().text(String.valueOf(part.bodyLines())); + } + if (extensible) { + sp().nstring(part.header("Content-MD5")).sp(); + disposition(part).sp(); + nstring(part.header("Content-Language")).sp(); + nstring(part.header("Content-Location")); + } + return text(")"); + } + + private ImapWriter params(ParameterList params) { + if (params == null || params.size() == 0) { + return text("NIL"); + } + text("("); + boolean first = true; + Enumeration names = params.getNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + if (!first) { + sp(); + } + first = false; + string(name.toUpperCase(Locale.ROOT)).sp().string(String.valueOf(params.get(name))); + } + return text(")"); + } + + private ImapWriter disposition(MimeTree part) { + String header = part.header("Content-Disposition"); + if (header == null) { + return text("NIL"); + } + try { + ContentDisposition disposition = new ContentDisposition(header); + text("(").string(disposition.getDisposition().toUpperCase(Locale.ROOT)).sp(); + params(disposition.getParameterList()); + return text(")"); + } catch (ParseException e) { + return text("NIL"); + } + } + + private static String firstNonNull(String a, String b) { + return a != null ? a : b; + } + + // ---------- modified UTF-7 (RFC 3501 5.1.3) for mailbox names ---------- + + static String encodeMailbox(String name) { + StringBuilder out = new StringBuilder(); + StringBuilder pending = new StringBuilder(); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c >= 0x20 && c <= 0x7e) { + flushBase64(out, pending); + out.append(c == '&' ? "&-" : String.valueOf(c)); + } else { + pending.append(c); + } + } + flushBase64(out, pending); + return out.toString(); + } + + private static void flushBase64(StringBuilder out, StringBuilder pending) { + if (pending.length() == 0) { + return; + } + byte[] utf16 = pending.toString().getBytes(StandardCharsets.UTF_16BE); + String base64 = java.util.Base64.getEncoder().withoutPadding().encodeToString(utf16).replace('/', ','); + out.append('&').append(base64).append('-'); + pending.setLength(0); + } + + static String decodeMailbox(String name) { + StringBuilder out = new StringBuilder(); + int i = 0; + while (i < name.length()) { + char c = name.charAt(i); + if (c != '&') { + out.append(c); + i++; + continue; + } + int dash = name.indexOf('-', i); + if (dash < 0) { + out.append(name.substring(i)); + break; + } + if (dash == i + 1) { + out.append('&'); + } else { + try { + byte[] utf16 = java.util.Base64.getDecoder().decode(name.substring(i + 1, dash).replace(',', '/')); + out.append(new String(utf16, StandardCharsets.UTF_16BE)); + } catch (IllegalArgumentException e) { + out.append(name, i, dash + 1); + } + } + i = dash + 1; + } + return out.toString(); + } +} diff --git a/backend/src/main/java/com/mailserver/imap/MimeSource.java b/backend/src/main/java/com/mailserver/imap/MimeSource.java new file mode 100644 index 0000000..13956af --- /dev/null +++ b/backend/src/main/java/com/mailserver/imap/MimeSource.java @@ -0,0 +1,129 @@ +package com.mailserver.imap; + +import com.mailserver.domain.Attachment; +import com.mailserver.domain.Message; +import jakarta.activation.DataHandler; +import jakarta.activation.FileDataSource; +import jakarta.mail.MessagingException; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeBodyPart; +import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeMultipart; +import jakarta.mail.internet.MimeUtility; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Properties; + +/** + * MIME text of a message that has no original (written in the web client): built from the stored fields and + * attachment files. The boundary and Message-ID come from the message id, so the result is the same every time. + */ +public final class MimeSource { + + private MimeSource() { + } + + public static byte[] build(Message message, List attachments, String hostname) + throws MessagingException, IOException { + Properties props = new Properties(); + props.put("mail.mime.splitlongparameters", "true"); + Session session = Session.getInstance(props); + String messageId = message.getMessageId() == null || message.getMessageId().isBlank() + ? message.getId() + "@" + hostname + : message.getMessageId().replaceAll("^<|>$", ""); + if (!messageId.contains("@")) { + messageId = messageId + "@" + hostname; + } + String finalMessageId = "<" + messageId + ">"; + MimeMessage mime = new MimeMessage(session) { + @Override + protected void updateMessageID() throws MessagingException { + setHeader("Message-ID", finalMessageId); + } + }; + + String from = message.getSenderEmail() == null ? "unknown@" + hostname : message.getSenderEmail(); + mime.setFrom(new InternetAddress(from, message.getSenderName(), "UTF-8")); + List to = addresses(message.getRecipientEmail()); + if (!to.isEmpty()) { + mime.setRecipients(jakarta.mail.Message.RecipientType.TO, to.toArray(new InternetAddress[0])); + } + List cc = addresses(message.getCcEmail()); + if (!cc.isEmpty()) { + mime.setRecipients(jakarta.mail.Message.RecipientType.CC, cc.toArray(new InternetAddress[0])); + } + mime.setSubject(message.getSubject() == null ? "" : message.getSubject(), "UTF-8"); + if (message.getReceivedAt() != null) { + mime.setSentDate(Date.from(message.getReceivedAt())); + } + + String body = message.getBody() == null ? "" : message.getBody(); + String subtype = message.isHtml() ? "html" : "plain"; + if (attachments.isEmpty()) { + mime.setText(body, "UTF-8", subtype); + } else { + MimeMultipart mixed = new FixedBoundaryMultipart("mixed", "=_rms_" + message.getId()); + MimeBodyPart text = new MimeBodyPart(); + text.setText(body, "UTF-8", subtype); + mixed.addBodyPart(text); + for (Attachment attachment : attachments) { + Path file = Path.of(attachment.getFilePath()); + if (!Files.isRegularFile(file)) { + continue; + } + MimeBodyPart part = new MimeBodyPart(); + FileDataSource source = new FileDataSource(file.toFile()) { + @Override + public String getContentType() { + return attachment.getContentType() == null ? "application/octet-stream" : attachment.getContentType(); + } + }; + part.setDataHandler(new DataHandler(source)); + part.setFileName(MimeUtility.encodeText(attachment.getFilename(), "UTF-8", null)); + part.setDisposition(attachment.getContentId() != null ? MimeBodyPart.INLINE : MimeBodyPart.ATTACHMENT); + if (attachment.getContentId() != null) { + part.setContentID("<" + attachment.getContentId().replaceAll("^<|>$", "") + ">"); + } + mixed.addBodyPart(part); + } + mime.setContent(mixed); + } + mime.saveChanges(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + mime.writeTo(out); + return out.toByteArray(); + } + + private static List addresses(String list) { + List result = new ArrayList<>(); + if (list == null || list.isBlank()) { + return result; + } + for (String one : list.split("[,;]")) { + String address = one.trim(); + if (!address.isEmpty()) { + try { + result.add(new InternetAddress(address)); + } catch (MessagingException ignored) { + // a broken address is left out, the message is still served + } + } + } + return result; + } + + /** MimeMultipart with a given boundary instead of a random one. */ + private static final class FixedBoundaryMultipart extends MimeMultipart { + FixedBoundaryMultipart(String subtype, String boundary) { + super(subtype); + this.contentType = "multipart/" + subtype + "; boundary=\"" + boundary + "\""; + } + } +} diff --git a/backend/src/main/java/com/mailserver/imap/MimeTree.java b/backend/src/main/java/com/mailserver/imap/MimeTree.java new file mode 100644 index 0000000..56eaef4 --- /dev/null +++ b/backend/src/main/java/com/mailserver/imap/MimeTree.java @@ -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 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 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 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 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 headerNames() { + List names = new ArrayList<>(); + Enumeration all = headers.getAllHeaders(); + while (all.hasMoreElements()) { + names.add(all.nextElement().getName()); + } + return names; + } +} diff --git a/backend/src/main/java/com/mailserver/repository/MessageRepository.java b/backend/src/main/java/com/mailserver/repository/MessageRepository.java index 3bbe5b9..c817cd6 100644 --- a/backend/src/main/java/com/mailserver/repository/MessageRepository.java +++ b/backend/src/main/java/com/mailserver/repository/MessageRepository.java @@ -15,10 +15,6 @@ public interface MessageRepository extends R2dbcRepository { Flux findByFolderId(Long folderId); - /** IMAP (switched off): the whole folder, newest first. */ - @Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC") - Flux findByFolderId(Long folderId, Pageable pageable); - @Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId}") Mono countByFolderId(Long folderId); diff --git a/backend/src/main/java/com/mailserver/service/LocalDelivery.java b/backend/src/main/java/com/mailserver/service/LocalDelivery.java index 8cca573..049ada5 100644 --- a/backend/src/main/java/com/mailserver/service/LocalDelivery.java +++ b/backend/src/main/java/com/mailserver/service/LocalDelivery.java @@ -38,9 +38,12 @@ public class LocalDelivery implements DeliveryWorker.BounceSender { private final SpfService spfService; private final DmarcService dmarcService; private final String hostname; + private final MessageSources sources; public LocalDelivery(MailService mailService, DkimService dkimService, SpfService spfService, - DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname) { + DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname, + MessageSources sources) { + this.sources = sources; this.mailService = mailService; this.dkimService = dkimService; this.spfService = spfService; @@ -82,11 +85,13 @@ public class LocalDelivery implements DeliveryWorker.BounceSender { for (FilePart file : attachments(mime)) { mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes()); } + // IMAP serves the message exactly as it arrived + sources.put(stored.getId(), raw).block(); } } /** "a@x, b@y" from a header, or null. A broken header is ignored, not a reason to lose the message. */ - static String addresses(MimeMessage mime, jakarta.mail.Message.RecipientType type) { + public static String addresses(MimeMessage mime, jakarta.mail.Message.RecipientType type) { try { jakarta.mail.Address[] list = mime.getRecipients(type); if (list == null || list.length == 0) { @@ -103,11 +108,11 @@ public class LocalDelivery implements DeliveryWorker.BounceSender { } } - record FilePart(String name, String contentType, byte[] bytes) { + public record FilePart(String name, String contentType, byte[] bytes) { } /** Parts with a file name (attachments and inline images), nested multiparts walked. */ - static List attachments(Part part) throws MessagingException, IOException { + public static List attachments(Part part) throws MessagingException, IOException { List files = new ArrayList<>(); collect(part, files); return files; @@ -144,11 +149,11 @@ public class LocalDelivery implements DeliveryWorker.BounceSender { } } - record Body(String text, boolean html) { + public record Body(String text, boolean html) { } /** HTML part when the message has one, otherwise the plain text; nested multiparts are walked. */ - static Body body(Part part) throws MessagingException, IOException { + public static Body body(Part part) throws MessagingException, IOException { String html = find(part, "text/html"); if (html != null) { return new Body(html, true); diff --git a/backend/src/main/java/com/mailserver/service/MailService.java b/backend/src/main/java/com/mailserver/service/MailService.java index 8ccc081..1837365 100644 --- a/backend/src/main/java/com/mailserver/service/MailService.java +++ b/backend/src/main/java/com/mailserver/service/MailService.java @@ -59,7 +59,8 @@ public class MailService { public MailService(MessageRepository messageRepository, FolderRepository folderRepository, UserRepository userRepository, AttachmentRepository attachmentRepository, ContactRepository contactRepository, - SpamFilterService spamFilterService) { + SpamFilterService spamFilterService, MessageSources sources) { + this.sources = sources; this.messageRepository = messageRepository; this.folderRepository = folderRepository; this.userRepository = userRepository; @@ -69,6 +70,8 @@ public class MailService { } /** System folders in the order the web client shows them; the rest — own folders, by name. */ + private final MessageSources sources; + public static final List SYSTEM_FOLDERS = List.of("INBOX", "Drafts", "Sent", "Spam", "Trash"); /** Messages per folder in one list — the web client sorts and pages them itself. */ @@ -212,6 +215,8 @@ public class MailService { message.setMessageSize((long) (subject.length() + body.length())); return messageRepository.save(message); }) + // IMAP: the draft changed — its MIME text is built anew + .flatMap(saved -> sources.remove(saved.getId()).thenReturn(saved)) .flatMap(this::withAttachmentCount); } @@ -226,6 +231,7 @@ public class MailService { /** After sending: the draft's attachments move to the copy in Sent, the draft itself is removed. */ public Mono finishDraft(Long draftId, Long sentMessageId) { return attachmentRepository.moveAll(draftId, sentMessageId) + .then(sources.remove(sentMessageId)) .then(messageRepository.deleteById(draftId)); } @@ -467,7 +473,7 @@ public class MailService { attachment.setContentType(contentType); attachment.setSizeBytes(sizeBytes); attachment.setFilePath(filePath); - return attachmentRepository.save(attachment); + return sources.remove(messageId).then(attachmentRepository.save(attachment)); }) .map(this::toAttachmentResponse); } @@ -491,7 +497,7 @@ public class MailService { return ownedAttachment(attachmentId, userId) .flatMap(attachment -> { deleteFile(attachment.getFilePath()); - return attachmentRepository.delete(attachment); + return sources.remove(attachment.getMessageId()).then(attachmentRepository.delete(attachment)); }); } diff --git a/backend/src/main/java/com/mailserver/service/MessageSources.java b/backend/src/main/java/com/mailserver/service/MessageSources.java new file mode 100644 index 0000000..da64635 --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/MessageSources.java @@ -0,0 +1,38 @@ +package com.mailserver.service; + +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +/** + * The message exactly as received or appended (table message_sources). IMAP serves it as BODY[]; a message without + * one gets its MIME text built once ({@link com.mailserver.imap.MimeSource}). A changed draft loses it. + */ +@Component +public class MessageSources { + + private final DatabaseClient db; + + public MessageSources(DatabaseClient db) { + this.db = db; + } + + public Mono get(Long messageId) { + return db.sql("SELECT raw FROM message_sources WHERE message_id = :id") + .bind("id", messageId) + .map(row -> row.get("raw", byte[].class)) + .one(); + } + + public Mono put(Long messageId, byte[] raw) { + return db.sql("INSERT INTO message_sources (message_id, raw) VALUES (:id, :raw) " + + "ON CONFLICT (message_id) DO UPDATE SET raw = EXCLUDED.raw") + .bind("id", messageId) + .bind("raw", raw) + .then(); + } + + public Mono remove(Long messageId) { + return db.sql("DELETE FROM message_sources WHERE message_id = :id").bind("id", messageId).then(); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 92feb9b..3293b05 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -57,10 +57,12 @@ mailserver.outbound.username=${OUTBOUND_USERNAME:} mailserver.outbound.password=${OUTBOUND_PASSWORD:} mailserver.outbound.tls=${OUTBOUND_TLS:starttls} -# IMAP — off: simplified, blocks the event loop (v0.2.3 75.2) -mailserver.imap.enabled=false -mailserver.imap.port=1143 -mailserver.imap.hostname=localhost +# IMAP (75.2): STARTTLS on IMAP_PORT, implicit TLS on IMAPS_PORT (993) if set; same certificate as SMTP. +# LOGIN only inside TLS unless IMAP_AUTH_WITHOUT_TLS=true (a closed network only) +mailserver.imap.enabled=${IMAP_ENABLED:true} +mailserver.imap.port=${IMAP_PORT:3143} +mailserver.imaps.port=${IMAPS_PORT:0} +mailserver.imap.auth-without-tls=${IMAP_AUTH_WITHOUT_TLS:false} # DKIM — signing of our mail; DNS record printed to the log at start mailserver.dkim.enabled=${DKIM_ENABLED:true} diff --git a/backend/src/main/resources/db/migration/V4__imap.sql b/backend/src/main/resources/db/migration/V4__imap.sql new file mode 100644 index 0000000..640bffa --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__imap.sql @@ -0,0 +1,43 @@ +-- IMAP (75.2): UIDs per folder, IMAP flags, the original message text + +-- UIDVALIDITY: never changes for a folder; UIDNEXT: the next UID to give out +ALTER TABLE folders ADD COLUMN uid_validity BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM now())::BIGINT); +ALTER TABLE folders ADD COLUMN uid_next BIGINT NOT NULL DEFAULT 1; + +-- UID inside the folder; strictly increasing — a message moved into a folder gets a new, larger UID +ALTER TABLE messages ADD COLUMN uid BIGINT; +ALTER TABLE messages ADD COLUMN is_answered BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE messages ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE; + +UPDATE messages m SET uid = numbered.n +FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY folder_id ORDER BY received_at, id) AS n FROM messages) numbered +WHERE m.id = numbered.id; + +UPDATE folders f SET uid_next = COALESCE((SELECT MAX(uid) FROM messages WHERE folder_id = f.id), 0) + 1; + +ALTER TABLE messages ALTER COLUMN uid SET NOT NULL; +CREATE UNIQUE INDEX idx_messages_folder_uid ON messages(folder_id, uid); + +-- The UID is given out by the database, so every path that stores or moves a message (SMTP, web client, IMAP) +-- gets it right without knowing about IMAP +CREATE FUNCTION assign_message_uid() RETURNS trigger AS $$ +BEGIN + UPDATE folders SET uid_next = uid_next + 1 WHERE id = NEW.folder_id RETURNING uid_next - 1 INTO NEW.uid; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER messages_uid_on_insert BEFORE INSERT ON messages + FOR EACH ROW EXECUTE FUNCTION assign_message_uid(); + +-- The web client saves the whole row (folder_id included) — only a real move gets a new UID +CREATE TRIGGER messages_uid_on_move BEFORE UPDATE OF folder_id ON messages + FOR EACH ROW WHEN (NEW.folder_id IS DISTINCT FROM OLD.folder_id) EXECUTE FUNCTION assign_message_uid(); + +-- The message exactly as received (SMTP) or appended (IMAP APPEND); for messages without it the server builds +-- the MIME text once from the stored fields and keeps it here, so sizes and bytes never change between fetches. +-- Removed when a draft changes. +CREATE TABLE message_sources ( + message_id BIGINT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + raw BYTEA NOT NULL +); diff --git a/backend/src/test/java/com/mailserver/imap/ImapServerTest.java b/backend/src/test/java/com/mailserver/imap/ImapServerTest.java new file mode 100644 index 0000000..0463c34 --- /dev/null +++ b/backend/src/test/java/com/mailserver/imap/ImapServerTest.java @@ -0,0 +1,345 @@ +package com.mailserver.imap; + +import com.mailserver.smtp.SmtpTls; +import jakarta.mail.BodyPart; +import jakarta.mail.Flags; +import jakarta.mail.Folder; +import jakarta.mail.Message; +import jakarta.mail.Multipart; +import jakarta.mail.Session; +import jakarta.mail.Store; +import jakarta.mail.UIDFolder; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import jakarta.mail.search.SubjectTerm; +import org.eclipse.angus.mail.imap.AppendUID; +import org.eclipse.angus.mail.imap.IMAPFolder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +class ImapServerTest { + + static final String PLAIN = """ + From: Bob + To: alice@example.test + Subject: Hello Alice + Date: Thu, 05 Nov 2026 10:00:00 +0000 + Message-ID: + Content-Type: text/plain; charset=utf-8 + + Hi there, line one. + Line two. + """; + + static final String WITH_FILE = """ + From: =?UTF-8?B?0JDQvdC90LA=?= + To: alice@example.test + Subject: =?UTF-8?B?0J7RgtGH0ZHRgg==?= + Date: Thu, 05 Nov 2026 11:00:00 +0000 + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="b1" + + preamble + --b1 + Content-Type: text/plain; charset=utf-8 + + See the report. + --b1 + Content-Type: application/pdf; name="report.pdf" + Content-Disposition: attachment; filename="report.pdf" + Content-Transfer-Encoding: base64 + + JVBERi0xLjQgdGVzdA== + --b1-- + """; + + @TempDir + Path tlsDir; + + private MemoryImapStore store; + private ImapServer server; + + @BeforeEach + void start() throws Exception { + store = new MemoryImapStore(); + store.deliver("INBOX", PLAIN); + store.deliver("INBOX", WITH_FILE); + server = new ImapServer(new ImapServer.Settings("127.0.0.1", 0, "mx.example.test", false, + SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), false, 1_000_000, + Duration.ofMinutes(5), Duration.ofMillis(200)), store); + server.start(); + } + + @AfterEach + void stop() { + server.stop(); + } + + private Store connect() throws Exception { + Properties props = new Properties(); + props.put("mail.imap.host", "127.0.0.1"); + props.put("mail.imap.port", String.valueOf(server.port())); + props.put("mail.imap.starttls.enable", "true"); + props.put("mail.imap.starttls.required", "true"); + props.put("mail.imap.ssl.trust", "*"); + props.put("mail.imap.ssl.checkserveridentity", "false"); + Store imap = Session.getInstance(props).getStore("imap"); + imap.connect("alice@example.test", "secret"); + return imap; + } + + @Test + void realClientReadsFoldersMessagesAndAttachments() throws Exception { + try (Store imap = connect()) { + List names = Arrays.stream(imap.getDefaultFolder().list("*")).map(Folder::getFullName).toList(); + assertThat(names).contains("INBOX", "Drafts", "Sent", "Spam", "Trash"); + + IMAPFolder inbox = (IMAPFolder) imap.getFolder("INBOX"); + inbox.open(Folder.READ_WRITE); + assertThat(inbox.getMessageCount()).isEqualTo(2); + assertThat(inbox.getUnreadMessageCount()).isEqualTo(2); + + Message first = inbox.getMessage(1); + assertThat(first.getSubject()).isEqualTo("Hello Alice"); + assertThat(((InternetAddress) first.getFrom()[0]).getPersonal()).isEqualTo("Bob"); + assertThat(first.getContent().toString()).contains("Line two."); + assertThat(first.isSet(Flags.Flag.SEEN)).isTrue(); // BODY[] without PEEK + + Message second = inbox.getMessage(2); + assertThat(second.getSubject()).isEqualTo("Отчёт"); + assertThat(((InternetAddress) second.getFrom()[0]).getPersonal()).isEqualTo("Анна"); + Multipart parts = (Multipart) second.getContent(); // BODYSTRUCTURE, then BODY[1] and BODY[2] + assertThat(parts.getCount()).isEqualTo(2); + assertThat(parts.getBodyPart(0).getContent().toString()).contains("See the report."); + BodyPart file = parts.getBodyPart(1); + assertThat(file.getFileName()).isEqualTo("report.pdf"); + assertThat(new String(file.getInputStream().readAllBytes(), StandardCharsets.US_ASCII)).isEqualTo("%PDF-1.4 test"); + + assertThat(inbox.search(new SubjectTerm("hello"))).hasSize(1); + assertThat(inbox.getUID(first)).isEqualTo(1); + assertThat(inbox.getMessageByUID(2).getSubject()).isEqualTo("Отчёт"); + } + } + + @Test + void appendCopyMoveAndExpunge() throws Exception { + try (Store imap = connect()) { + IMAPFolder sent = (IMAPFolder) imap.getFolder("Sent"); + MimeMessage outgoing = new MimeMessage(Session.getInstance(new Properties())); + outgoing.setFrom(new InternetAddress("alice@example.test")); + outgoing.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@elsewhere.test")); + outgoing.setSubject("Sent from Thunderbird"); + outgoing.setText("body"); + AppendUID[] appended = sent.appendUIDMessages(new Message[]{outgoing}); + assertThat(appended[0].uid).isEqualTo(1); + assertThat(appended[0].uidvalidity).isEqualTo(store.box("Sent").uidValidity()); + + IMAPFolder inbox = (IMAPFolder) imap.getFolder("INBOX"); + inbox.open(Folder.READ_WRITE); + inbox.copyMessages(new Message[]{inbox.getMessage(1)}, imap.getFolder("Trash")); + inbox.moveMessages(new Message[]{inbox.getMessage(2)}, imap.getFolder("Spam")); + inbox.close(false); // JavaMail counts EXPUNGEd messages until the folder is opened again + inbox.open(Folder.READ_WRITE); + assertThat(inbox.getMessageCount()).isEqualTo(1); + + inbox.getMessage(1).setFlag(Flags.Flag.DELETED, true); + inbox.expunge(); + assertThat(inbox.getMessageCount()).isZero(); + } + assertThat(store.entries(store.box("Trash").id())).hasSize(1); + assertThat(store.entries(store.box("Spam").id())).hasSize(1); + assertThat(store.entries(store.box("Sent").id())).hasSize(1); + } + + @Test + void createRenameAndDeleteFolders() throws Exception { + try (Store imap = connect()) { + Folder projects = imap.getFolder("Проекты"); + assertThat(projects.create(Folder.HOLDS_MESSAGES)).isTrue(); + assertThat(Arrays.stream(imap.getDefaultFolder().list("*")).map(Folder::getFullName)).contains("Проекты"); + assertThat(projects.renameTo(imap.getFolder("Archive"))).isTrue(); + assertThat(imap.getFolder("Archive").delete(false)).isTrue(); + assertThat(imap.getFolder("Archive").exists()).isFalse(); + } + } + + @Test + void loginIsRefusedInPlainTextAndTheGreetingSaysSo() throws Exception { + try (Client client = new Client(server.port())) { + String greeting = client.read(); + assertThat(greeting).contains("STARTTLS").contains("LOGINDISABLED").doesNotContain("AUTH=PLAIN"); + assertThat(client.send("a LOGIN alice@example.test secret")).startsWith("a NO [PRIVACYREQUIRED]"); + } + } + + @Test + void literalsAndLiteralPlus() throws Exception { + try (Client client = new Client(server.port())) { + client.read(); + client.tls("s"); + client.write("a LOGIN {18}\r\n"); + assertThat(client.read()).startsWith("+ "); + client.write("alice@example.test {6+}\r\nsecret\r\n"); + assertThat(client.read()).startsWith("a OK"); + String message = "Subject: via literal+\r\n\r\nhi\r\n"; + client.write("b APPEND Drafts (\\Seen) {" + message.length() + "+}\r\n" + message + "\r\n"); + assertThat(client.read()).startsWith("b OK [APPENDUID"); + } + assertThat(store.entries(store.box("Drafts").id())).hasSize(1); + } + + @Test + void idleReportsNewMailUntilDone() throws Exception { + try (Client client = new Client(server.port())) { + client.read(); + client.tls("s"); + client.send("a LOGIN alice@example.test secret"); + client.untilTagged("b", "b SELECT INBOX"); + client.write("c IDLE\r\n"); + assertThat(client.read()).startsWith("+ idling"); + store.deliver("INBOX", PLAIN); + assertThat(client.read()).isEqualTo("* 3 EXISTS"); + client.write("DONE\r\n"); + assertThat(client.read()).startsWith("c OK"); + assertThat(client.send("d NOOP")).startsWith("d OK"); + } + } + + // CVE-2011-0411 class: commands sent in plain text right after STARTTLS must not run inside TLS + @Test + void plainTextAfterStartTlsIsNotExecuted() throws Exception { + try (Client client = new Client(server.port())) { + client.read(); + client.write("s STARTTLS\r\nx LOGIN alice@example.test secret\r\n"); + assertThat(client.read()).startsWith("s OK"); + String reply; + try { + client.startTls(); + reply = client.read(); + } catch (java.io.IOException closed) { + reply = null; // the server closed the connection — the expected outcome + } + assertThat(reply).isNull(); + } + } + + @Test + void sectionsOfANestedMessage() { + String raw = ("Subject: outer\r\nContent-Type: multipart/mixed; boundary=x\r\n\r\n" + + "--x\r\nContent-Type: text/plain\r\n\r\nouter text\r\n" + + "--x\r\nContent-Type: message/rfc822\r\n\r\nSubject: inner\r\nContent-Type: text/plain\r\n\r\ninner text\r\n" + + "--x--\r\n"); + MimeTree tree = MimeTree.parse(raw.getBytes(StandardCharsets.US_ASCII)); + assertThat(text(ImapSession.section(tree, "1"))).isEqualTo("outer text"); + assertThat(text(ImapSession.section(tree, "2.HEADER"))).isEqualTo("Subject: inner\r\nContent-Type: text/plain\r\n\r\n"); + assertThat(text(ImapSession.section(tree, "2.TEXT"))).isEqualTo("inner text"); + assertThat(text(ImapSession.section(tree, "2.1"))).isEqualTo("inner text"); + assertThat(text(ImapSession.section(tree, "1.MIME"))).isEqualTo("Content-Type: text/plain\r\n\r\n"); + assertThat(text(ImapSession.section(tree, "HEADER.FIELDS (SUBJECT)"))).isEqualTo("Subject: outer\r\n\r\n"); + assertThat(text(ImapSession.section(tree, ""))).isEqualTo(raw); + } + + @Test + void mailboxNamesUseModifiedUtf7() { + // the example of RFC 3501 5.1.3 + assertThat(ImapWriter.encodeMailbox("~peter/mail/台北/日本語")).isEqualTo("~peter/mail/&U,BTFw-/&ZeVnLIqe-"); + assertThat(ImapWriter.decodeMailbox("~peter/mail/&U,BTFw-/&ZeVnLIqe-")).isEqualTo("~peter/mail/台北/日本語"); + assertThat(ImapWriter.decodeMailbox(ImapWriter.encodeMailbox("Проекты & план"))).isEqualTo("Проекты & план"); + } + + private static String text(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + + /** Line-oriented IMAP test client. */ + static final class Client implements AutoCloseable { + private Socket socket; + private BufferedReader in; + private OutputStream out; + + Client(int port) throws Exception { + socket = new Socket("127.0.0.1", port); + socket.setSoTimeout(5000); + in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + out = socket.getOutputStream(); + } + + void write(String text) throws Exception { + out.write(text.getBytes(StandardCharsets.UTF_8)); + out.flush(); + } + + String read() throws Exception { + return in.readLine(); + } + + String send(String line) throws Exception { + write(line + "\r\n"); + return read(); + } + + /** Sends a command, returns all lines up to its tagged response. */ + List untilTagged(String tag, String line) throws Exception { + write(line + "\r\n"); + List lines = new ArrayList<>(); + String next; + do { + next = read(); + lines.add(next); + } while (next != null && !next.startsWith(tag + " ")); + return lines; + } + + void tls(String tag) throws Exception { + assertThat(send(tag + " STARTTLS")).startsWith(tag + " OK"); + startTls(); + } + + void startTls() throws Exception { + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[]{new X509TrustManager() { + public void checkClientTrusted(X509Certificate[] chain, String authType) { + } + + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }}, null); + SSLSocket ssl = (SSLSocket) context.getSocketFactory().createSocket(socket, "127.0.0.1", socket.getPort(), true); + ssl.setUseClientMode(true); + ssl.startHandshake(); + socket = ssl; + in = new BufferedReader(new InputStreamReader(ssl.getInputStream(), StandardCharsets.UTF_8)); + out = ssl.getOutputStream(); + } + + @Override + public void close() throws Exception { + socket.close(); + } + } +} diff --git a/backend/src/test/java/com/mailserver/imap/MemoryImapStore.java b/backend/src/test/java/com/mailserver/imap/MemoryImapStore.java new file mode 100644 index 0000000..ef76d3c --- /dev/null +++ b/backend/src/test/java/com/mailserver/imap/MemoryImapStore.java @@ -0,0 +1,172 @@ +package com.mailserver.imap; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicLong; + +/** In-memory {@link ImapStore}: one user alice@example.test / secret with the system folders. */ +class MemoryImapStore implements ImapStore { + + static final class Stored { + final long id; + volatile long folderId; + volatile long uid; + volatile boolean seen; + volatile boolean flagged; + volatile boolean answered; + volatile boolean deleted; + final Instant date; + final byte[] raw; + + Stored(long id, long folderId, long uid, byte[] raw, Instant date) { + this.id = id; + this.folderId = folderId; + this.uid = uid; + this.raw = raw; + this.date = date; + } + + Entry entry() { + return new Entry(id, uid, seen, flagged, answered, deleted, date); + } + } + + private final AtomicLong ids = new AtomicLong(100); + final List boxes = new CopyOnWriteArrayList<>(); + final Map uidNext = new ConcurrentHashMap<>(); + final List messages = new CopyOnWriteArrayList<>(); + + MemoryImapStore() { + for (String name : List.of("INBOX", "Drafts", "Sent", "Spam", "Trash")) { + addBox(name, true); + } + } + + private Mailbox addBox(String name, boolean system) { + long id = ids.incrementAndGet(); + uidNext.put(id, 1L); + Mailbox box = new Mailbox(id, name, system, 1700000000L + id, 1); + boxes.add(box); + return box; + } + + Mailbox box(String name) { + return boxes.stream().filter(b -> b.name().equals(name)).findFirst().orElseThrow(); + } + + /** Delivers a message into a folder, as SMTP would. */ + synchronized Stored deliver(String folder, String raw) { + Mailbox box = box(folder); + Stored stored = new Stored(ids.incrementAndGet(), box.id(), nextUid(box.id()), + raw.replace("\n", "\r\n").replace("\r\r\n", "\r\n").getBytes(), Instant.parse("2026-11-05T10:00:00Z")); + messages.add(stored); + return stored; + } + + private synchronized long nextUid(long folderId) { + long uid = uidNext.get(folderId); + uidNext.put(folderId, uid + 1); + return uid; + } + + @Override + public Account authenticate(String username, String password) { + return "alice@example.test".equalsIgnoreCase(username) && "secret".equals(password) + ? new Account(1, "alice@example.test") : null; + } + + @Override + public List mailboxes(long userId) { + return boxes.stream().map(b -> new Mailbox(b.id(), b.name(), b.system(), b.uidValidity(), uidNext.get(b.id()))).toList(); + } + + @Override + public synchronized Mailbox create(long userId, String name) { + return addBox(name, false); + } + + @Override + public synchronized void rename(long userId, Mailbox mailbox, String newName) { + boxes.removeIf(b -> b.id() == mailbox.id()); + boxes.add(new Mailbox(mailbox.id(), newName, false, mailbox.uidValidity(), mailbox.uidNext())); + } + + @Override + public synchronized void delete(long userId, Mailbox mailbox) { + Mailbox trash = box("Trash"); + move(userId, entries(mailbox.id()), trash); + boxes.removeIf(b -> b.id() == mailbox.id()); + } + + @Override + public List entries(long folderId) { + return messages.stream().filter(m -> m.folderId == folderId) + .sorted((a, b) -> Long.compare(a.uid, b.uid)).map(Stored::entry).toList(); + } + + @Override + public byte[] source(long messageId) { + return find(messageId).raw; + } + + private Stored find(long id) { + return messages.stream().filter(m -> m.id == id).findFirst().orElseThrow(); + } + + @Override + public void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted) { + Stored m = find(messageId); + m.seen = seen; + m.flagged = flagged; + m.answered = answered; + m.deleted = deleted; + } + + @Override + public synchronized long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged, + boolean answered, boolean deleted, Instant internalDate) { + Stored stored = new Stored(ids.incrementAndGet(), target.id(), nextUid(target.id()), raw, + internalDate == null ? Instant.now() : internalDate); + stored.seen = seen; + stored.flagged = flagged; + stored.answered = answered; + stored.deleted = deleted; + messages.add(stored); + return stored.uid; + } + + @Override + public synchronized List copy(long userId, List list, Mailbox target) { + List uids = new ArrayList<>(); + for (Entry entry : list) { + Stored from = find(entry.id()); + Stored copy = new Stored(ids.incrementAndGet(), target.id(), nextUid(target.id()), from.raw, from.date); + copy.seen = from.seen; + copy.flagged = from.flagged; + messages.add(copy); + uids.add(copy.uid); + } + return uids; + } + + @Override + public synchronized List move(long userId, List list, Mailbox target) { + List uids = new ArrayList<>(); + for (Entry entry : list) { + Stored m = find(entry.id()); + m.folderId = target.id(); + m.uid = nextUid(target.id()); + uids.add(m.uid); + } + return uids; + } + + @Override + public void expunge(List messageIds) { + messages.removeIf(m -> messageIds.contains(m.id)); + } +} diff --git a/docker-compose.jambotron.yml b/docker-compose.jambotron.yml index 674daed..d2434a2 100644 --- a/docker-compose.jambotron.yml +++ b/docker-compose.jambotron.yml @@ -56,6 +56,7 @@ services: expose: - "8080" # web client + API → domain in Jambotron - "3025" # SMTP, inside the network only + - "3143" # IMAP (STARTTLS), inside the network only — open it in Jambotron as an internal TCP port environment: DB_HOST: db MAILSERVER_SECRETS_DIR: /run/mail-secrets