diff --git a/providers/angus-mail/src/test/java/org/eclipse/angus/mail/imap/IdleTimeoutTest.java b/providers/angus-mail/src/test/java/org/eclipse/angus/mail/imap/IdleTimeoutTest.java new file mode 100644 index 00000000..f1195b00 --- /dev/null +++ b/providers/angus-mail/src/test/java/org/eclipse/angus/mail/imap/IdleTimeoutTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. All rights reserved. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + */ + +package org.eclipse.angus.mail.imap; +import java.util.Properties; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import jakarta.mail.Folder; +import jakarta.mail.Session; +import jakarta.mail.Store; + +import org.eclipse.angus.mail.test.SingleThreadServer; +import org.junit.Test; + +public class IdleTimeoutTest { + + private static final long TIMEOUT = 3000; + private final Executor executor = Executors.newCachedThreadPool(); + + @Test + public void testTimeout() throws Exception { + try (SingleThreadServer server = new SingleThreadServer()) { + + // Predefine the server responses, so it is mocking an IMAP server + + server.prepareResponse("A0 CAPABILITY\r\n", + "* CAPABILITY IMAP4rev1 STARTTLS AUTH=PLAIN AUTH=LOGIN IDLE UIDPLUS\r\n" + + "A0 OK CAPABILITY completed\r\n"); + + server.prepareResponse("A1 AUTHENTICATE PLAIN\r\n", "+\r\n"); + server.prepareResponse("AHVzZXJAdGVzdC5jb20AMTIzNA==\r\n", + "A1 OK AUTHENTICATE completed\r\n"); + + server.prepareResponse("A2 CAPABILITY\r\n", + "* CAPABILITY IMAP4rev1 IDLE UIDPLUS\r\n" + + "A2 OK CAPABILITY completed\r\n"); + + server.prepareResponse("A3 SELECT INBOX\r\n", + "* 0 EXISTS\r\n" + + "* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)\r\n" + + "A3 OK [READ-WRITE] SELECT completed\r\n"); + + server.prepareResponse("A4 IDLE\r\n", "+ idling\r\n"); + + server.start(); + Properties properties = new Properties(); + properties.setProperty("mail.imap.host", "localhost"); + properties.setProperty("mail.imap.port", Integer.toString(server.getPort())); + // We want to verify what happens when client gets SocketTimeoutException + properties.setProperty("mail.imap.timeout", Long.toString(TIMEOUT)); + // Required to IDLE + properties.setProperty("mail.imap.usesocketchannels", "true"); + Session session = Session.getDefaultInstance(properties); + Store store = session.getStore("imap"); + store.connect("user@test.com", "1234"); + IdleManager im = new IdleManager(session, executor); + Folder folder = store.getFolder("INBOX"); + folder.open(Folder.READ_WRITE); + + im.watch(folder); + // Client is going to get the SocketTimeoutException after TIMEOUT + Thread.sleep(9999999L); + } + } + +} diff --git a/providers/angus-mail/src/test/java/org/eclipse/angus/mail/test/SingleThreadServer.java b/providers/angus-mail/src/test/java/org/eclipse/angus/mail/test/SingleThreadServer.java new file mode 100644 index 00000000..dd8d53de --- /dev/null +++ b/providers/angus-mail/src/test/java/org/eclipse/angus/mail/test/SingleThreadServer.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. All rights reserved. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0, which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the + * Eclipse Public License v. 2.0 are satisfied: GNU General Public License, + * version 2 with the GNU Classpath Exception, which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + */ + +package org.eclipse.angus.mail.test; + +import java.io.Closeable; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class SingleThreadServer implements AutoCloseable { + + private int port; + private ServerSocket server; + private Socket serverSocket; + private ExecutorService executor; + private volatile boolean running; + private Map predefinedResponses = new HashMap<>(); + + public void start() { + try { + executor = Executors.newSingleThreadExecutor(); + server = new ServerSocket(0); + port = server.getLocalPort(); + executor.submit(() -> { + try { + serverSocket = server.accept(); + System.out.println("Connected to " + serverSocket.getRemoteSocketAddress()); + System.out.println("Server > * OK IMAP4rev1 Service Ready\r\n"); + writeInServer("* OK IMAP4rev1 Service Ready\r\n"); + running = true; + byte[] buffer = new byte[1024]; + int bytesRead; + while (running && (bytesRead = serverSocket.getInputStream().read(buffer)) != -1) { + String data = new String(buffer, 0, bytesRead); + System.out.println("Client > " + data); + String response = predefinedResponses.get(data); + if (response != null) { + System.out.println("Server > " + response); + writeInServer(response); + } + } + } catch (IOException e1) { + throw new UncheckedIOException(e1); + } + }); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public void prepareResponse(String clientSends, String serverResponds) { + predefinedResponses.put(clientSends, serverResponds); + } + + public void stop() { + if (running) { + running = false; + close(serverSocket); + serverSocket = null; + close(server); + server = null; + if (executor != null) { + executor.shutdown(); + executor = null; + } + } + } + + private void close(Closeable c) { + if (c != null) { + try { + c.close(); + } catch (IOException e) {} + } + } + + private void writeInServer(String data) { + try { + OutputStream out = serverSocket.getOutputStream(); + out.write(data.getBytes()); + out.flush(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public int getPort() { + return port; + } + + @Override + public void close() throws Exception { + stop(); + } +} diff --git a/providers/imap/src/main/java/org/eclipse/angus/mail/imap/IMAPFolder.java b/providers/imap/src/main/java/org/eclipse/angus/mail/imap/IMAPFolder.java index a54bcb5d..ff5e465b 100644 --- a/providers/imap/src/main/java/org/eclipse/angus/mail/imap/IMAPFolder.java +++ b/providers/imap/src/main/java/org/eclipse/angus/mail/imap/IMAPFolder.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 1997, 2023 Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 1997, 2025 Oracle and/or its affiliates. All rights reserved. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0, which is available at @@ -3262,7 +3262,9 @@ boolean handleIdle(boolean once) throws MessagingException { if (r.isBYE() && r.isSynthetic() && idleState == IDLE) { /* * If it was a timeout and no bytes were transferred - * we ignore it and go back and read again. + * we abort IDLE and request IDLE again, + * so the server can continue pushing. + * * If the I/O was otherwise interrupted, and no * bytes were transferred, we take it as a request * to abort the IDLE. @@ -3272,9 +3274,13 @@ boolean handleIdle(boolean once) throws MessagingException { ((InterruptedIOException) ex). bytesTransferred == 0) { if (ex instanceof SocketTimeoutException) { - logger.finest( - "handleIdle: ignoring socket timeout"); - r = null; // repeat do/while loop + logger.finest("handleIdle: socket timeout"); + // Next aborts and read responses + idleAbortWait(); + // Reissue the IDLE + if (startIdle(idleManager)) { + r = null; // repeat do/while loop + } } else { logger.finest("handleIdle: interrupting IDLE"); IdleManager im = idleManager;