Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b72be91a8b |
@@ -31,6 +31,17 @@
|
||||
- **DKIM.** Подпись — `utils-mail-dkim` (rsa-sha256, relaxed/relaxed), проверка входящих — Apache James jDKIM по ключу из DNS отправителя. Ключ создаётся один раз в `DKIM_KEYS_DIR`, запись для DNS печатается в лог при старте: `TXT <selector>._domainkey.<domain>`.
|
||||
- **Права.** Письма, папки и вложения отдаются только владельцу (чужие — `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:`) показываются как вложения, а не внутри письма.
|
||||
- [ ] Защита регистрации и входа от перебора (лимит попыток, капча).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
|
||||
java.util.List<com.mailserver.domain.Message> 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<Long> 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<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
|
||||
java.util.List<com.mailserver.domain.Message> 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<com.mailserver.domain.Folder> 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<com.mailserver.domain.Folder> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<Mailbox> 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<Entry> 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<Attachment> 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<Long> copy(long userId, List<Entry> list, Mailbox target) {
|
||||
boolean spam = target.system() && "Spam".equals(target.name());
|
||||
List<Long> 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<Long> move(long userId, List<Entry> list, Mailbox target) {
|
||||
boolean spam = target.system() && "Spam".equals(target.name());
|
||||
List<Long> 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<Long> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> list() {
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <ul>
|
||||
* <li>{@link CommandDecoder} collects whole commands with their literals ({n} — after "+ Ready", {n+} — at once);</li>
|
||||
* <li>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;</li>
|
||||
* <li>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;</li>
|
||||
* <li>IDLE: changes of the selected mailbox are pushed every {@code idlePoll} until DONE.</li>
|
||||
* </ul>
|
||||
*/
|
||||
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<Void>[] idleDone = new Sinks.Empty[1];
|
||||
|
||||
Flux<byte[]> 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<byte[]> idle(ImapSession session, String tag, Sinks.Empty<Void> done) {
|
||||
Flux<byte[]> 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<byte[]> blocking(java.util.concurrent.Callable<List<byte[]>> work) {
|
||||
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()).flatMapIterable(list -> list);
|
||||
}
|
||||
|
||||
private static Mono<Void> 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<Void> 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<Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Mailbox> 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<Entry> 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<Long> copy(long userId, List<Entry> messages, Mailbox target);
|
||||
|
||||
/** Moves; returns the new UIDs in the same order. */
|
||||
List<Long> move(long userId, List<Entry> messages, Mailbox target);
|
||||
|
||||
/** Removes the messages for good (with attachment files). */
|
||||
void expunge(List<Long> messageIds);
|
||||
}
|
||||
@@ -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<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<Attachment> 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<InternetAddress> to = addresses(message.getRecipientEmail());
|
||||
if (!to.isEmpty()) {
|
||||
mime.setRecipients(jakarta.mail.Message.RecipientType.TO, to.toArray(new InternetAddress[0]));
|
||||
}
|
||||
List<InternetAddress> 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<InternetAddress> addresses(String list) {
|
||||
List<InternetAddress> 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 + "\"";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.mailserver.imap;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.ContentType;
|
||||
import jakarta.mail.internet.InternetHeaders;
|
||||
import jakarta.mail.internet.ParseException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A message split into MIME parts by byte offsets — what IMAP needs for BODYSTRUCTURE and BODY[section]:
|
||||
* every part is a range of the original bytes, so sizes and contents match BODY[] exactly.
|
||||
*/
|
||||
final class MimeTree {
|
||||
|
||||
private static final int MAX_DEPTH = 20;
|
||||
|
||||
final byte[] raw;
|
||||
/** Part = [start, end); headers = [start, bodyStart), the blank line included. */
|
||||
final int start;
|
||||
final int bodyStart;
|
||||
final int end;
|
||||
final InternetHeaders headers;
|
||||
final ContentType type;
|
||||
final List<MimeTree> children;
|
||||
/** The encapsulated message of a message/rfc822 part. */
|
||||
final MimeTree message;
|
||||
|
||||
private MimeTree(byte[] raw, int start, int end, int depth) {
|
||||
this.raw = raw;
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
this.bodyStart = headerEnd(raw, start, end);
|
||||
this.headers = parseHeaders(raw, start, bodyStart);
|
||||
this.type = contentType(headers);
|
||||
List<MimeTree> parts = new ArrayList<>();
|
||||
MimeTree nested = null;
|
||||
if (depth < MAX_DEPTH) {
|
||||
if (isMultipart()) {
|
||||
String boundary = type.getParameter("boundary");
|
||||
if (boundary != null && !boundary.isEmpty()) {
|
||||
split(boundary, depth, parts);
|
||||
}
|
||||
} else if (is("message", "rfc822") || is("message", "global")) {
|
||||
nested = new MimeTree(raw, bodyStart, end, depth + 1);
|
||||
}
|
||||
}
|
||||
this.children = Collections.unmodifiableList(parts);
|
||||
this.message = nested;
|
||||
}
|
||||
|
||||
static MimeTree parse(byte[] raw) {
|
||||
return new MimeTree(raw, 0, raw.length, 0);
|
||||
}
|
||||
|
||||
boolean isMultipart() {
|
||||
return "multipart".equalsIgnoreCase(type.getPrimaryType());
|
||||
}
|
||||
|
||||
boolean is(String primary, String sub) {
|
||||
return primary.equalsIgnoreCase(type.getPrimaryType()) && sub.equalsIgnoreCase(type.getSubType());
|
||||
}
|
||||
|
||||
byte[] all() {
|
||||
return slice(start, end);
|
||||
}
|
||||
|
||||
byte[] header() {
|
||||
return slice(start, bodyStart);
|
||||
}
|
||||
|
||||
byte[] body() {
|
||||
return slice(bodyStart, end);
|
||||
}
|
||||
|
||||
int bodySize() {
|
||||
return end - bodyStart;
|
||||
}
|
||||
|
||||
/** Lines of the body (for text parts in BODYSTRUCTURE). */
|
||||
int bodyLines() {
|
||||
int lines = 0;
|
||||
for (int i = bodyStart; i < end; i++) {
|
||||
if (raw[i] == '\n') {
|
||||
lines++;
|
||||
}
|
||||
}
|
||||
if (end > bodyStart && raw[end - 1] != '\n') {
|
||||
lines++;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Header value unfolded, or null. */
|
||||
String header(String name) {
|
||||
String[] values = headers.getHeader(name);
|
||||
if (values == null || values.length == 0 || values[0] == null) {
|
||||
return null;
|
||||
}
|
||||
return values[0].replaceAll("\r?\n(?=[ \t])", "").trim();
|
||||
}
|
||||
|
||||
/** Only these header fields (or all but these), each with its folded lines, plus the blank line. */
|
||||
byte[] headerFields(Set<String> names, boolean not) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
String text = new String(raw, start, bodyStart - start, StandardCharsets.ISO_8859_1);
|
||||
String[] lines = text.split("\r?\n", -1);
|
||||
boolean keep = false;
|
||||
for (String line : lines) {
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (line.charAt(0) == ' ' || line.charAt(0) == '\t') {
|
||||
if (keep) {
|
||||
out.append(line).append("\r\n");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
int colon = line.indexOf(':');
|
||||
String name = colon > 0 ? line.substring(0, colon).trim().toUpperCase(Locale.ROOT) : "";
|
||||
keep = names.contains(name) != not;
|
||||
if (keep) {
|
||||
out.append(line).append("\r\n");
|
||||
}
|
||||
}
|
||||
out.append("\r\n");
|
||||
return out.toString().getBytes(StandardCharsets.ISO_8859_1);
|
||||
}
|
||||
|
||||
private byte[] slice(int from, int to) {
|
||||
byte[] copy = new byte[Math.max(0, to - from)];
|
||||
System.arraycopy(raw, from, copy, 0, copy.length);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Body parts between "--boundary" lines; the CRLF before a delimiter belongs to the delimiter. */
|
||||
private void split(String boundary, int depth, List<MimeTree> parts) {
|
||||
byte[] delimiter = ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1);
|
||||
int partStart = -1;
|
||||
int i = bodyStart;
|
||||
while (i < end) {
|
||||
int lineEnd = indexOf(raw, (byte) '\n', i, end);
|
||||
int next = lineEnd < 0 ? end : lineEnd + 1;
|
||||
if (startsWith(raw, i, end, delimiter)) {
|
||||
int after = i + delimiter.length;
|
||||
boolean close = after + 1 < end && raw[after] == '-' && raw[after + 1] == '-';
|
||||
if (partStart >= 0) {
|
||||
int partEnd = i;
|
||||
if (partEnd > partStart && raw[partEnd - 1] == '\n') {
|
||||
partEnd--;
|
||||
if (partEnd > partStart && raw[partEnd - 1] == '\r') {
|
||||
partEnd--;
|
||||
}
|
||||
}
|
||||
parts.add(new MimeTree(raw, partStart, Math.max(partStart, partEnd), depth + 1));
|
||||
}
|
||||
if (close) {
|
||||
return;
|
||||
}
|
||||
partStart = next;
|
||||
}
|
||||
i = next;
|
||||
}
|
||||
if (partStart >= 0 && partStart < end) { // no closing delimiter — take the rest
|
||||
parts.add(new MimeTree(raw, partStart, end, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
private static int headerEnd(byte[] raw, int start, int end) {
|
||||
if (start < end && raw[start] == '\n') {
|
||||
return start + 1;
|
||||
}
|
||||
if (start + 1 < end && raw[start] == '\r' && raw[start + 1] == '\n') {
|
||||
return start + 2;
|
||||
}
|
||||
for (int i = start; i < end; i++) {
|
||||
if (raw[i] == '\n') {
|
||||
if (i + 1 < end && raw[i + 1] == '\n') {
|
||||
return i + 2;
|
||||
}
|
||||
if (i + 2 < end && raw[i + 1] == '\r' && raw[i + 2] == '\n') {
|
||||
return i + 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
return end; // headers only
|
||||
}
|
||||
|
||||
private static InternetHeaders parseHeaders(byte[] raw, int start, int end) {
|
||||
try {
|
||||
return new InternetHeaders(new ByteArrayInputStream(raw, start, end - start));
|
||||
} catch (MessagingException e) {
|
||||
return new InternetHeaders();
|
||||
}
|
||||
}
|
||||
|
||||
private static ContentType contentType(InternetHeaders headers) {
|
||||
String[] values = headers.getHeader("Content-Type");
|
||||
if (values != null && values.length > 0 && values[0] != null) {
|
||||
try {
|
||||
return new ContentType(values[0].replaceAll("\r?\n(?=[ \t])", "").trim());
|
||||
} catch (ParseException ignored) {
|
||||
// broken header — the RFC 2045 default
|
||||
}
|
||||
}
|
||||
try {
|
||||
return new ContentType("text/plain; charset=us-ascii");
|
||||
} catch (ParseException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean startsWith(byte[] raw, int at, int end, byte[] prefix) {
|
||||
if (at + prefix.length > end) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < prefix.length; i++) {
|
||||
if (raw[at + i] != prefix[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int indexOf(byte[] raw, byte value, int from, int end) {
|
||||
for (int i = from; i < end; i++) {
|
||||
if (raw[i] == value) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Header names of the part, for tests and debugging. */
|
||||
List<String> headerNames() {
|
||||
List<String> names = new ArrayList<>();
|
||||
Enumeration<jakarta.mail.Header> all = headers.getAllHeaders();
|
||||
while (all.hasMoreElements()) {
|
||||
names.add(all.nextElement().getName());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,6 @@ public interface MessageRepository extends R2dbcRepository<Message, Long> {
|
||||
|
||||
Flux<Message> 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<Message> findByFolderId(Long folderId, Pageable pageable);
|
||||
|
||||
@Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId}")
|
||||
Mono<Long> countByFolderId(Long folderId);
|
||||
|
||||
|
||||
@@ -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<FilePart> attachments(Part part) throws MessagingException, IOException {
|
||||
public static List<FilePart> attachments(Part part) throws MessagingException, IOException {
|
||||
List<FilePart> 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);
|
||||
|
||||
@@ -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<String> 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<Void> 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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<byte[]> 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<Void> 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<Void> remove(Long messageId) {
|
||||
return db.sql("DELETE FROM message_sources WHERE message_id = :id").bind("id", messageId).then();
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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 <bob@elsewhere.test>
|
||||
To: alice@example.test
|
||||
Subject: Hello Alice
|
||||
Date: Thu, 05 Nov 2026 10:00:00 +0000
|
||||
Message-ID: <hello@elsewhere.test>
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
|
||||
Hi there, line one.
|
||||
Line two.
|
||||
""";
|
||||
|
||||
static final String WITH_FILE = """
|
||||
From: =?UTF-8?B?0JDQvdC90LA=?= <anna@elsewhere.test>
|
||||
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<String> 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<String> untilTagged(String tag, String line) throws Exception {
|
||||
write(line + "\r\n");
|
||||
List<String> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Mailbox> boxes = new CopyOnWriteArrayList<>();
|
||||
final Map<Long, Long> uidNext = new ConcurrentHashMap<>();
|
||||
final List<Stored> 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<Mailbox> 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<Entry> 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<Long> copy(long userId, List<Entry> list, Mailbox target) {
|
||||
List<Long> 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<Long> move(long userId, List<Entry> list, Mailbox target) {
|
||||
List<Long> 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<Long> messageIds) {
|
||||
messages.removeIf(m -> messageIds.contains(m.id));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user