From 9ede4df5bedcf9d44d207301c3226cb90a1d657e Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Thu, 16 Jul 2026 21:02:52 +0200 Subject: [PATCH] Implement IMAP IDLE --- ROADMAP.md | 3 +- src/agents/imap/CMakeLists.txt | 10 ++++ src/agents/imap/capabilities.c | 2 +- src/agents/imap/idle.c | 54 ++++++++++++++++++ src/agents/imap/idle.h | 19 +++++++ src/agents/imap/imapd.c | 56 ++++++++++++++++++ src/agents/imap/imapd.h | 2 + src/agents/imap/tests/capabilities-test.c | 1 + src/agents/imap/tests/idle-test.c | 69 +++++++++++++++++++++++ 9 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 src/agents/imap/idle.c create mode 100644 src/agents/imap/idle.h create mode 100644 src/agents/imap/tests/idle-test.c diff --git a/ROADMAP.md b/ROADMAP.md index 5265945..ee89b5a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -74,7 +74,8 @@ and tested. app passwords and optional OAUTHBEARER/EXTERNAL. Never advertise mechanisms that are unavailable or unsafe on the current connection. - Add POP3 SASL and integrate its authentication with app-password policy. -- Add IMAP IDLE, UIDPLUS, MOVE, SPECIAL-USE, LIST-EXTENDED and LITERAL+. +- IMAP IDLE is implemented with store-event polling and a 29-minute renewal + boundary. Add UIDPLUS, MOVE, SPECIAL-USE, LIST-EXTENDED and LITERAL+. - Add IMAP ENABLE and UTF8=ACCEPT, then advertise IMAP4rev2 only after every mandatory rev2 behavior and interoperability test passes. - Implement SMTPUTF8 end to end through submission, relay, LMTP, queue, DSN, diff --git a/src/agents/imap/CMakeLists.txt b/src/agents/imap/CMakeLists.txt index 5967b0e..2b097dc 100644 --- a/src/agents/imap/CMakeLists.txt +++ b/src/agents/imap/CMakeLists.txt @@ -1,6 +1,7 @@ add_executable(bongoimap imapd.c capabilities.c + idle.c copy.c event.c fetch.c @@ -33,4 +34,13 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR} ) add_test(NAME imap-capabilities COMMAND imap-capabilities-test) + + add_executable(imap-idle-test + tests/idle-test.c + idle.c + ) + target_include_directories(imap-idle-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ) + add_test(NAME imap-idle COMMAND imap-idle-test) endif() diff --git a/src/agents/imap/capabilities.c b/src/agents/imap/capabilities.c index 7a91e7c..7ed059a 100644 --- a/src/agents/imap/capabilities.c +++ b/src/agents/imap/capabilities.c @@ -34,7 +34,7 @@ IMAPFormatCapabilities(char *buffer, size_t buffer_size, buffer[0] = '\0'; if (!Append(buffer, buffer_size, &length, - "* CAPABILITY IMAP4rev1 IMAP4 NAMESPACE XSENDER") || + "* CAPABILITY IMAP4rev1 IMAP4 NAMESPACE IDLE XSENDER") || (!authenticated && tls_active && !Append(buffer, buffer_size, &length, " AUTH=PLAIN AUTH=LOGIN SASL-IR")) || diff --git a/src/agents/imap/idle.c b/src/agents/imap/idle.c new file mode 100644 index 0000000..111843e --- /dev/null +++ b/src/agents/imap/idle.c @@ -0,0 +1,54 @@ +#include "idle.h" + +#include +#include +#include +#include + +IMAPIdleResult +IMAPIdleRun(int socket_fd, unsigned int event_interval_seconds, + unsigned int timeout_seconds, IMAPIdleReadLine read_line, + IMAPIdlePollEvents poll_events, void *context) +{ + time_t now; + time_t deadline; + time_t next_event_check; + + if (socket_fd < 0 || !read_line || !poll_events || + event_interval_seconds == 0) + return IMAP_IDLE_ERROR; + + now = time(NULL); + if (now == (time_t)-1) return IMAP_IDLE_ERROR; + deadline = now + timeout_seconds; + next_event_check = now; + + for (;;) { + fd_set readable; + struct timeval timeout; + const char *line = NULL; + int ready; + + now = time(NULL); + if (now == (time_t)-1) return IMAP_IDLE_ERROR; + if (now >= deadline) return IMAP_IDLE_TIMEOUT; + if (now >= next_event_check) { + if (!poll_events(context)) return IMAP_IDLE_ERROR; + next_event_check = now + event_interval_seconds; + } + + FD_ZERO(&readable); + FD_SET(socket_fd, &readable); + timeout.tv_sec = 1; + timeout.tv_usec = 0; + ready = select(socket_fd + 1, &readable, NULL, NULL, &timeout); + if (ready < 0) { + if (errno == EINTR) continue; + return IMAP_IDLE_ERROR; + } + if (ready == 0) continue; + if (!read_line(context, &line) || !line) return IMAP_IDLE_ERROR; + return strcasecmp(line, "DONE") == 0 ? IMAP_IDLE_DONE + : IMAP_IDLE_INVALID; + } +} diff --git a/src/agents/imap/idle.h b/src/agents/imap/idle.h new file mode 100644 index 0000000..fe0bf65 --- /dev/null +++ b/src/agents/imap/idle.h @@ -0,0 +1,19 @@ +#ifndef BONGO_IMAP_IDLE_H +#define BONGO_IMAP_IDLE_H + +typedef int (*IMAPIdleReadLine)(void *context, const char **line); +typedef int (*IMAPIdlePollEvents)(void *context); + +typedef enum { + IMAP_IDLE_ERROR = -1, + IMAP_IDLE_TIMEOUT = -2, + IMAP_IDLE_INVALID = 0, + IMAP_IDLE_DONE = 1 +} IMAPIdleResult; + +IMAPIdleResult IMAPIdleRun(int socket_fd, unsigned int event_interval_seconds, + unsigned int timeout_seconds, + IMAPIdleReadLine read_line, + IMAPIdlePollEvents poll_events, void *context); + +#endif diff --git a/src/agents/imap/imapd.c b/src/agents/imap/imapd.c index b8f3c6b..7372ecc 100644 --- a/src/agents/imap/imapd.c +++ b/src/agents/imap/imapd.c @@ -44,6 +44,7 @@ #include "imapd.h" #include "capabilities.h" +#include "idle.h" ImapGlobal Imap; @@ -55,6 +56,7 @@ static ProtocolCommand ImapProtocolCommands[] = { IMAP_PROTOCOL_COMMAND(IMAP_COMMAND_CAPABILITY, ImapCommandCapability), IMAP_PROTOCOL_COMMAND(IMAP_COMMAND_NOOP, ImapCommandNoop), IMAP_PROTOCOL_COMMAND(IMAP_COMMAND_LOGOUT, ImapCommandLogout), + IMAP_PROTOCOL_COMMAND(IMAP_COMMAND_IDLE, ImapCommandIdle), IMAP_PROTOCOL_COMMAND(IMAP_COMMAND_STARTTLS, ImapCommandStartTls), IMAP_PROTOCOL_COMMAND(IMAP_COMMAND_AUTHENTICATE, ImapCommandAuthenticate), IMAP_PROTOCOL_COMMAND(IMAP_COMMAND_LOGIN, ImapCommandLogin), @@ -892,6 +894,60 @@ ImapCommandNoop(void *param) return(SendError(session->client.conn, session->command.tag, "NOOP", ccode)); } +static int +IdleReadLine(void *opaque, const char **line) +{ + ImapSession *session = opaque; + + if (ReadCommandLine(session->client.conn, &session->command.buffer, + &session->command.bufferLen) != STATUS_CONTINUE) + return 0; + *line = session->command.buffer; + return 1; +} + +static int +IdlePollEvents(void *opaque) +{ + ImapSession *session = opaque; + + return EventsSend(session, STORE_EVENT_ALL) == STATUS_CONTINUE && + ConnFlush(session->client.conn) != -1; +} + +int +ImapCommandIdle(void *param) +{ + ImapSession *session = (ImapSession *)param; + const char *arguments = session->command.buffer + strlen(IMAP_COMMAND_IDLE); + IMAPIdleResult result; + + while (IsWhiteSpace(*arguments)) arguments++; + if (*arguments != '\0') + return SendError(session->client.conn, session->command.tag, "IDLE", + STATUS_INVALID_ARGUMENT); + if (session->client.state < STATE_AUTH) + return SendError(session->client.conn, session->command.tag, "IDLE", + STATUS_INVALID_STATE); + if (ConnWrite(session->client.conn, "+ idling\r\n", + sizeof("+ idling\r\n") - 1) == -1 || + ConnFlush(session->client.conn) == -1) + return STATUS_ABORT; + + result = IMAPIdleRun(session->client.conn->socket, 5, 29 * 60, + IdleReadLine, IdlePollEvents, session); + if (result == IMAP_IDLE_DONE) return SendOk(session, "IDLE"); + if (result == IMAP_IDLE_INVALID) + return SendError(session->client.conn, session->command.tag, "IDLE", + STATUS_INVALID_ARGUMENT); + if (result == IMAP_IDLE_TIMEOUT) { + ConnWrite(session->client.conn, "* BYE IDLE timeout\r\n", + sizeof("* BYE IDLE timeout\r\n") - 1); + ConnFlush(session->client.conn); + } + return STATUS_ABORT; +} + int ImapCommandLogout(void *param) { diff --git a/src/agents/imap/imapd.h b/src/agents/imap/imapd.h index 88d1916..3a5d510 100755 --- a/src/agents/imap/imapd.h +++ b/src/agents/imap/imapd.h @@ -152,6 +152,7 @@ static ImapErrorString ImapErrorStrings[] = { #define IMAP_COMMAND_CAPABILITY "CAPABILITY" #define IMAP_COMMAND_NOOP "NOOP" #define IMAP_COMMAND_LOGOUT "LOGOUT" +#define IMAP_COMMAND_IDLE "IDLE" /* IMAP session commands - not authenticated state */ #define IMAP_COMMAND_STARTTLS "STARTTLS" @@ -429,6 +430,7 @@ BOOL CommandSearchInit(void); int ImapCommandCapability(void *param); int ImapCommandNoop(void *param); int ImapCommandLogout(void *param); +int ImapCommandIdle(void *param); int ImapCommandStartTls(void *param); int ImapCommandAuthenticate(void *param); int ImapCommandLogin(void *param); diff --git a/src/agents/imap/tests/capabilities-test.c b/src/agents/imap/tests/capabilities-test.c index e3c4525..5e3564a 100644 --- a/src/agents/imap/tests/capabilities-test.c +++ b/src/agents/imap/tests/capabilities-test.c @@ -13,6 +13,7 @@ main(void) assert(strstr(buffer, "IMAP4rev1")); assert(strstr(buffer, " IMAP4 ")); assert(strstr(buffer, "NAMESPACE")); + assert(strstr(buffer, "IDLE")); assert(strstr(buffer, "XSENDER")); assert(strstr(buffer, "LOGINDISABLED")); assert(!strstr(buffer, "STARTTLS")); diff --git a/src/agents/imap/tests/idle-test.c b/src/agents/imap/tests/idle-test.c new file mode 100644 index 0000000..ad544bd --- /dev/null +++ b/src/agents/imap/tests/idle-test.c @@ -0,0 +1,69 @@ +#include "idle.h" + +#include +#include +#include +#include + +typedef struct { + int socket; + int polls; + char line[32]; +} TestContext; + +static int +ReadLine(void *opaque, const char **line) +{ + TestContext *context = opaque; + ssize_t length = read(context->socket, context->line, + sizeof(context->line) - 1); + + if (length <= 0) return 0; + while (length > 0 && + (context->line[length - 1] == '\r' || + context->line[length - 1] == '\n')) + length--; + context->line[length] = '\0'; + *line = context->line; + return 1; +} + +static int +PollEvents(void *opaque) +{ + TestContext *context = opaque; + context->polls++; + return 1; +} + +static void +TestLine(const char *line, IMAPIdleResult expected) +{ + int sockets[2]; + TestContext context; + + assert(socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) == 0); + memset(&context, 0, sizeof(context)); + context.socket = sockets[0]; + assert(write(sockets[1], line, strlen(line)) == (ssize_t)strlen(line)); + assert(IMAPIdleRun(sockets[0], 5, 30, ReadLine, PollEvents, &context) == + expected); + assert(context.polls == 1); + close(sockets[0]); + close(sockets[1]); +} + +int +main(void) +{ + TestContext context = { -1, 0, "" }; + + TestLine("DONE\r\n", IMAP_IDLE_DONE); + TestLine("done\r\n", IMAP_IDLE_DONE); + TestLine("NOOP\r\n", IMAP_IDLE_INVALID); + assert(IMAPIdleRun(0, 5, 0, ReadLine, PollEvents, &context) == + IMAP_IDLE_TIMEOUT); + assert(IMAPIdleRun(-1, 5, 30, ReadLine, PollEvents, &context) == + IMAP_IDLE_ERROR); + return 0; +}