75.2 IMAP: новый сервер на Reactor Netty (STARTTLS, 993, IDLE, UIDPLUS, MOVE)
Старый IMAP (выключен, .block() на сетевом потоке) удалён, написан заново:
- ImapServer: декодер целых команд с литералами ({n} после "+ Ready", {n+}),
команды по одной на рабочем потоке; STARTTLS (без выполнения открытого текста
после него) и неявный TLS; IDLE с опросом раз в 5 с до DONE
- ImapSession: IMAP4rev1 + LITERAL+, SASL-IR, IDLE, UIDPLUS, MOVE, UNSELECT,
NAMESPACE, SPECIAL-USE, ID; FETCH (ENVELOPE, BODYSTRUCTURE, BODY[section]<partial>),
SEARCH, STORE, COPY/MOVE с COPYUID, APPEND с APPENDUID, EXPUNGE/UID EXPUNGE
- MimeTree: части письма по байтам — размеры BODYSTRUCTURE совпадают с BODY[]
- V4: UID на папку выдаёт триггер (перенос из веба тоже получает новый UID),
флаги \Answered/\Deleted, message_sources — письмо как пришло (SMTP, APPEND)
или собранное один раз (MimeSource); правка черновика сбрасывает его
- тесты: настоящий клиент Angus IMAP, сырые литералы/IDLE/подмешивание (47 всего)
- живая проверка: обновление V3 -> V4 на PostgreSQL с письмами, Python imaplib:
STARTTLS, 993, папки, вложения, поиск, APPEND/COPY/MOVE/EXPUNGE, IDLE — 0 ошибок
α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user