diff --git a/src/agents/imap/CMakeLists.txt b/src/agents/imap/CMakeLists.txt index 91890fc..b9b8f39 100644 --- a/src/agents/imap/CMakeLists.txt +++ b/src/agents/imap/CMakeLists.txt @@ -4,6 +4,7 @@ add_executable(bongoimap idle.c uidplus.c special-use.c + list-extended.c copy.c event.c fetch.c @@ -63,4 +64,13 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR} ) add_test(NAME imap-special-use COMMAND imap-special-use-test) + + add_executable(imap-list-extended-test + tests/list-extended-test.c + list-extended.c + ) + target_include_directories(imap-list-extended-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ) + add_test(NAME imap-list-extended COMMAND imap-list-extended-test) endif() diff --git a/src/agents/imap/capabilities.c b/src/agents/imap/capabilities.c index 2133de9..b14893a 100644 --- a/src/agents/imap/capabilities.c +++ b/src/agents/imap/capabilities.c @@ -56,7 +56,7 @@ IMAPFormatCapabilities(char *buffer, size_t buffer_size, if (!Append(buffer, buffer_size, &length, "* CAPABILITY IMAP4rev1 IMAP4 NAMESPACE IDLE UIDPLUS MOVE " - "SPECIAL-USE CREATE-SPECIAL-USE XSENDER") || + "SPECIAL-USE CREATE-SPECIAL-USE LIST-EXTENDED XSENDER") || (!authenticated && tls_active && !Append(buffer, buffer_size, &length, " AUTH=PLAIN AUTH=LOGIN SASL-IR")) || diff --git a/src/agents/imap/imapd.c b/src/agents/imap/imapd.c index 8d98db7..aba24fd 100644 --- a/src/agents/imap/imapd.c +++ b/src/agents/imap/imapd.c @@ -47,6 +47,7 @@ #include "idle.h" #include "uidplus.h" #include "special-use.h" +#include "list-extended.h" ImapGlobal Imap; @@ -1797,6 +1798,247 @@ SendFolderInfo(Connection *conn, const char *command, FolderInformation *folder) return(STATUS_ABORT); } +#define IMAP_LIST_MAX_PATTERNS 16 + +typedef struct { + unsigned int selectionOptions; + unsigned int returnOptions; + size_t patternCount; + char patterns[IMAP_LIST_MAX_PATTERNS][STORE_MAX_COLLECTION * 2]; +} ExtendedListRequest; + +static long +ListAddPattern(ExtendedListRequest *request, const char *reference, + const char *pattern) +{ + int written; + + if (request->patternCount == IMAP_LIST_MAX_PATTERNS) + return STATUS_INVALID_ARGUMENT; + if (!*reference) { + written = snprintf(request->patterns[request->patternCount], + sizeof(request->patterns[0]), "%s", pattern); + } else if (!*pattern) { + written = snprintf(request->patterns[request->patternCount], + sizeof(request->patterns[0]), "%s", reference); + } else { + written = snprintf(request->patterns[request->patternCount], + sizeof(request->patterns[0]), "%s/%s", + reference, pattern); + } + if (written < 0 || (size_t)written >= sizeof(request->patterns[0])) + return STATUS_PATH_TOO_LONG; + request->patternCount++; + return STATUS_CONTINUE; +} + +static long +ParseExtendedListRequest(ImapSession *session, char *start, + ExtendedListRequest *request) +{ + FolderPath reference; + FolderPath pattern; + char *ptr; + char *argument = NULL; + long ccode; + + memset(request, 0, sizeof(*request)); + ptr = FindNextArgument(start); + if (!ptr) return STATUS_INVALID_ARGUMENT; + + if (*ptr == '(') { + ccode = GrabArgument(session, &ptr, &argument); + if (ccode != STATUS_CONTINUE) return ccode; + if (!IMAPListParseSelectionOptions(argument, + &request->selectionOptions)) { + MemFree(argument); + return STATUS_INVALID_ARGUMENT; + } + MemFree(argument); + argument = NULL; + if (request->selectionOptions == 0) return STATUS_INVALID_ARGUMENT; + } + + ccode = GetPathArgument(session, ptr, &ptr, &reference, TRUE); + if (ccode != STATUS_CONTINUE) return ccode; + + { + char *patternStart = FindNextArgument(ptr); + if (!patternStart) { + FreePathArgument(&reference); + return STATUS_INVALID_ARGUMENT; + } + if (*patternStart == '(') { + char *patterns[IMAP_LIST_MAX_PATTERNS]; + size_t count; + size_t i; + + ptr = patternStart; + ccode = GrabArgument(session, &ptr, &argument); + if (ccode != STATUS_CONTINUE) { + FreePathArgument(&reference); + return ccode; + } + count = IMAPListSplitPatterns(argument, patterns, + IMAP_LIST_MAX_PATTERNS); + if (count == 0) ccode = STATUS_INVALID_ARGUMENT; + for (i = 0; i < count && ccode == STATUS_CONTINUE; i++) { + ccode = ListAddPattern(request, reference.name, patterns[i]); + } + MemFree(argument); + argument = NULL; + } else { + ccode = GetPathArgument(session, ptr, &ptr, &pattern, TRUE); + if (ccode == STATUS_CONTINUE) { + ccode = ListAddPattern(request, reference.name, pattern.name); + FreePathArgument(&pattern); + } + } + } + FreePathArgument(&reference); + if (ccode != STATUS_CONTINUE) return ccode; + + ptr = FindNextArgument(ptr); + if (ptr) { + ccode = GrabArgument(session, &ptr, &argument); + if (ccode != STATUS_CONTINUE) return ccode; + if (XplStrCaseCmp(argument, "RETURN") != 0) { + MemFree(argument); + return STATUS_INVALID_ARGUMENT; + } + MemFree(argument); + argument = NULL; + + ptr = FindNextArgument(ptr); + if (!ptr || *ptr != '(') return STATUS_INVALID_ARGUMENT; + ccode = GrabArgument(session, &ptr, &argument); + if (ccode != STATUS_CONTINUE) return ccode; + if (!IMAPListParseReturnOptions(argument, &request->returnOptions)) { + MemFree(argument); + return STATUS_INVALID_ARGUMENT; + } + MemFree(argument); + if (request->returnOptions == 0) return STATUS_INVALID_ARGUMENT; + if (FindNextArgument(ptr)) return STATUS_INVALID_ARGUMENT; + } + + if (request->selectionOptions & IMAP_LIST_SELECT_SUBSCRIBED) + request->returnOptions |= IMAP_LIST_RETURN_SUBSCRIBED; + return STATUS_CONTINUE; +} + +static BOOL +FolderMatchesBaseSelection(const FolderInformation *folder, + unsigned int selectionOptions) +{ + if (selectionOptions & IMAP_LIST_SELECT_REMOTE) return FALSE; + if ((selectionOptions & IMAP_LIST_SELECT_SUBSCRIBED) && + (folder->flags & STORE_COLLECTION_FLAG_NON_SUBSCRIBED)) return FALSE; + if ((selectionOptions & IMAP_LIST_SELECT_SPECIAL_USE) && + !(folder->flags & STORE_COLLECTION_FLAG_SPECIAL_USE_MASK)) return FALSE; + return TRUE; +} + +static BOOL +FolderHasMatchingDescendant(ImapSession *session, FolderInformation *folder, + unsigned int selectionOptions) +{ + unsigned long i; + size_t length = strlen(folder->name.utf7); + + for (i = (unsigned long)(folder - session->folder.list) + 1; + i < session->folder.count; i++) { + FolderInformation *candidate = &session->folder.list[i]; + if (strncmp(candidate->name.utf7, folder->name.utf7, length) != 0 || + candidate->name.utf7[length] != '/') break; + if (FolderMatchesBaseSelection(candidate, selectionOptions)) return TRUE; + } + return FALSE; +} + +static BOOL +FolderMatchesPatterns(const ExtendedListRequest *request, const char *name) +{ + size_t i; + for (i = 0; i < request->patternCount; i++) { + if (IMAPListPatternMatches(request->patterns[i], name)) return TRUE; + } + return FALSE; +} + +static long +SendExtendedFolderInfo(ImapSession *session, FolderInformation *folder, + unsigned int returnOptions) +{ + char attributes[192]; + char specialUse[96]; + size_t length = 0; + size_t specialUseLength; +#define APPEND_LIST_ATTRIBUTE(value) do { \ + const char *attribute_ = (value); \ + size_t attribute_length_ = strlen(attribute_); \ + if (length + (length ? 1 : 0) + attribute_length_ >= sizeof(attributes)) \ + return STATUS_ABORT; \ + if (length) attributes[length++] = ' '; \ + memcpy(attributes + length, attribute_, attribute_length_ + 1); \ + length += attribute_length_; \ + } while (0) + + attributes[0] = '\0'; + if (folder->flags & STORE_COLLECTION_FLAG_HIERARCHY_ONLY) + APPEND_LIST_ATTRIBUTE("\\NoSelect"); + if ((returnOptions & IMAP_LIST_RETURN_SUBSCRIBED) && + !(folder->flags & STORE_COLLECTION_FLAG_NON_SUBSCRIBED)) + APPEND_LIST_ATTRIBUTE("\\Subscribed"); + if (returnOptions & IMAP_LIST_RETURN_CHILDREN) { + APPEND_LIST_ATTRIBUTE(FolderHasSubFolders(session, folder) ? + "\\HasChildren" : "\\HasNoChildren"); + } + specialUseLength = IMAPSpecialUseFormat((uint32_t)folder->flags, + specialUse, sizeof(specialUse)); + if (specialUseLength) APPEND_LIST_ATTRIBUTE(specialUse); + +#undef APPEND_LIST_ATTRIBUTE + if (ConnWriteF(session->client.conn, "* LIST (%s) \"/\" \"%s\"\r\n", + attributes, folder->name.utf7) != -1) + return STATUS_CONTINUE; + return STATUS_ABORT; +} + +static long +SendExtendedList(ImapSession *session, const ExtendedListRequest *request) +{ + unsigned long i; + + /* Preserve the base LIST delimiter query used by old clients. */ + if (request->patternCount == 1 && request->patterns[0][0] == '\0' && + request->selectionOptions == 0) { + FolderInformation delimiter; + memset(&delimiter, 0, sizeof(delimiter)); + delimiter.name.utf7 = ""; + delimiter.flags = STORE_COLLECTION_FLAG_HIERARCHY_ONLY; + return SendFolderInfo(session->client.conn, "LIST", &delimiter); + } + + for (i = 0; i < session->folder.count; i++) { + FolderInformation *folder = &session->folder.list[i]; + BOOL selected; + + if (!FolderMatchesPatterns(request, folder->name.utf7)) continue; + selected = FolderMatchesBaseSelection(folder, + request->selectionOptions); + if (!selected && + (request->selectionOptions & IMAP_LIST_SELECT_RECURSIVE)) { + selected = FolderHasMatchingDescendant(session, folder, + request->selectionOptions); + } + if (!selected) continue; + if (SendExtendedFolderInfo(session, folder, request->returnOptions) != + STATUS_CONTINUE) return STATUS_ABORT; + } + return STATUS_CONTINUE; +} + __inline static int SendMatchingFolders(ImapSession *session, const char *command, char *pattern, const BOOL skipUnsubscribed, const BOOL skipSubFolders) @@ -1958,14 +2200,13 @@ ImapCommandList(void *param) ImapSession *session = (ImapSession *)param; long ccode; - char pattern[STORE_MAX_COLLECTION * 2]; - unsigned long patternLen = 0; + ExtendedListRequest request; if ((ccode = CheckState(session, STATE_AUTH)) == STATUS_CONTINUE) { if ((ccode = EventsSend(session, STORE_EVENT_ALL)) == STATUS_CONTINUE) { - if ((ccode = GetSearchPattern(session, session->command.buffer + strlen("LIST"), pattern, sizeof(pattern), &patternLen)) == STATUS_CONTINUE) { + if ((ccode = ParseExtendedListRequest(session, session->command.buffer + strlen("LIST"), &request)) == STATUS_CONTINUE) { if ((ccode = FolderListLoad(session)) == STATUS_CONTINUE) { - if ((ccode = FolderSend(session, "LIST", pattern, patternLen, FALSE)) == STATUS_CONTINUE) { + if ((ccode = SendExtendedList(session, &request)) == STATUS_CONTINUE) { return(SendOk(session, "LIST")); } } diff --git a/src/agents/imap/list-extended.c b/src/agents/imap/list-extended.c new file mode 100644 index 0000000..d23dd04 --- /dev/null +++ b/src/agents/imap/list-extended.c @@ -0,0 +1,185 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, contact Novell, Inc. + * + * To contact Novell about this file by physical or electronic mail, you + * may find current contact information at www.novell.com. + * + ****************************************************************************/ + +#include "list-extended.h" + +#include +#include +#include + +typedef struct { + const char *name; + unsigned int value; +} ListOption; + +static int +TokenEquals(const char *start, size_t length, const char *expected) +{ + size_t i; + if (strlen(expected) != length) return 0; + for (i = 0; i < length; i++) { + if (tolower((unsigned char)start[i]) != + tolower((unsigned char)expected[i])) return 0; + } + return 1; +} + +static int +ParseOptions(const char *text, const ListOption *known, size_t known_count, + unsigned int *options) +{ + unsigned int result = 0; + + if (!text || !options) return 0; + for (;;) { + const char *start; + size_t i; + + while (*text && isspace((unsigned char)*text)) text++; + if (!*text) { + *options = result; + return 1; + } + start = text; + while (*text && !isspace((unsigned char)*text)) text++; + for (i = 0; i < known_count; i++) { + if (TokenEquals(start, (size_t)(text - start), known[i].name)) { + if (result & known[i].value) return 0; + result |= known[i].value; + break; + } + } + if (i == known_count) return 0; + } +} + +int +IMAPListParseSelectionOptions(const char *text, unsigned int *options) +{ + static const ListOption Known[] = { + { "SUBSCRIBED", IMAP_LIST_SELECT_SUBSCRIBED }, + { "REMOTE", IMAP_LIST_SELECT_REMOTE }, + { "RECURSIVEMATCH", IMAP_LIST_SELECT_RECURSIVE }, + { "SPECIAL-USE", IMAP_LIST_SELECT_SPECIAL_USE } + }; + unsigned int parsed; + + if (!ParseOptions(text, Known, sizeof(Known) / sizeof(Known[0]), &parsed)) + return 0; + if ((parsed & IMAP_LIST_SELECT_RECURSIVE) && + !(parsed & IMAP_LIST_SELECT_SUBSCRIBED)) return 0; + *options = parsed; + return 1; +} + +int +IMAPListParseReturnOptions(const char *text, unsigned int *options) +{ + static const ListOption Known[] = { + { "SUBSCRIBED", IMAP_LIST_RETURN_SUBSCRIBED }, + { "CHILDREN", IMAP_LIST_RETURN_CHILDREN }, + { "SPECIAL-USE", IMAP_LIST_RETURN_SPECIAL_USE } + }; + return ParseOptions(text, Known, sizeof(Known) / sizeof(Known[0]), options); +} + +int +IMAPListPatternMatches(const char *pattern, const char *mailbox) +{ + size_t pattern_length; + size_t mailbox_length; + unsigned char *previous; + unsigned char *current; + size_t i; + size_t j; + int result; + + if (!pattern || !mailbox) return 0; + pattern_length = strlen(pattern); + mailbox_length = strlen(mailbox); + previous = calloc(mailbox_length + 1, 1); + current = calloc(mailbox_length + 1, 1); + if (!previous || !current) { + free(previous); + free(current); + return 0; + } + previous[0] = 1; + + for (i = 1; i <= pattern_length; i++) { + unsigned char *swap; + memset(current, 0, mailbox_length + 1); + if (pattern[i - 1] == '*') current[0] = previous[0]; + for (j = 1; j <= mailbox_length; j++) { + if (pattern[i - 1] == '*') { + current[j] = previous[j] || current[j - 1]; + } else if (pattern[i - 1] == '%') { + current[j] = previous[j] || + (mailbox[j - 1] != '/' && current[j - 1]); + } else { + current[j] = previous[j - 1] && + pattern[i - 1] == mailbox[j - 1]; + } + } + swap = previous; + previous = current; + current = swap; + } + result = previous[mailbox_length] != 0; + free(previous); + free(current); + return result; +} + +size_t +IMAPListSplitPatterns(char *text, char **patterns, size_t capacity) +{ + size_t count = 0; + + if (!text || !patterns || capacity == 0) return 0; + while (*text) { + char *start; + char *write; + + while (*text && isspace((unsigned char)*text)) text++; + if (!*text) break; + if (count == capacity) return 0; + + if (*text == '"') { + text++; + start = write = text; + while (*text && *text != '"') { + if (*text == '\\' && text[1]) text++; + *write++ = *text++; + } + if (*text != '"') return 0; + text++; + *write = '\0'; + if (*text && !isspace((unsigned char)*text)) return 0; + } else { + start = text; + while (*text && !isspace((unsigned char)*text)) text++; + if (*text) *text++ = '\0'; + } + patterns[count++] = start; + } + return count; +} diff --git a/src/agents/imap/list-extended.h b/src/agents/imap/list-extended.h new file mode 100644 index 0000000..cf64584 --- /dev/null +++ b/src/agents/imap/list-extended.h @@ -0,0 +1,41 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, contact Novell, Inc. + * + * To contact Novell about this file by physical or electronic mail, you + * may find current contact information at www.novell.com. + * + ****************************************************************************/ + +#ifndef BONGO_IMAP_LIST_EXTENDED_H +#define BONGO_IMAP_LIST_EXTENDED_H + +#include + +#define IMAP_LIST_SELECT_SUBSCRIBED (1u << 0) +#define IMAP_LIST_SELECT_REMOTE (1u << 1) +#define IMAP_LIST_SELECT_RECURSIVE (1u << 2) +#define IMAP_LIST_SELECT_SPECIAL_USE (1u << 3) + +#define IMAP_LIST_RETURN_SUBSCRIBED (1u << 0) +#define IMAP_LIST_RETURN_CHILDREN (1u << 1) +#define IMAP_LIST_RETURN_SPECIAL_USE (1u << 2) + +int IMAPListParseSelectionOptions(const char *text, unsigned int *options); +int IMAPListParseReturnOptions(const char *text, unsigned int *options); +int IMAPListPatternMatches(const char *pattern, const char *mailbox); +size_t IMAPListSplitPatterns(char *text, char **patterns, size_t capacity); + +#endif diff --git a/src/agents/imap/tests/capabilities-test.c b/src/agents/imap/tests/capabilities-test.c index 51b0e96..493b1ce 100644 --- a/src/agents/imap/tests/capabilities-test.c +++ b/src/agents/imap/tests/capabilities-test.c @@ -39,6 +39,7 @@ main(void) assert(strstr(buffer, "MOVE")); assert(strstr(buffer, "SPECIAL-USE")); assert(strstr(buffer, "CREATE-SPECIAL-USE")); + assert(strstr(buffer, "LIST-EXTENDED")); assert(strstr(buffer, "XSENDER")); assert(strstr(buffer, "LOGINDISABLED")); assert(!strstr(buffer, "STARTTLS")); diff --git a/src/agents/imap/tests/list-extended-test.c b/src/agents/imap/tests/list-extended-test.c new file mode 100644 index 0000000..8a065b4 --- /dev/null +++ b/src/agents/imap/tests/list-extended-test.c @@ -0,0 +1,58 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, contact Novell, Inc. + * + * To contact Novell about this file by physical or electronic mail, you + * may find current contact information at www.novell.com. + * + ****************************************************************************/ + +#include "list-extended.h" + +#include +#include + +int +main(void) +{ + unsigned int options; + char group[] = "\"INBOX/%\" Archive/* Plain"; + char *patterns[4]; + size_t count; + + assert(IMAPListParseSelectionOptions("SUBSCRIBED RECURSIVEMATCH", &options)); + assert(options == (IMAP_LIST_SELECT_SUBSCRIBED | + IMAP_LIST_SELECT_RECURSIVE)); + assert(IMAPListParseSelectionOptions("SPECIAL-USE", &options)); + assert(options == IMAP_LIST_SELECT_SPECIAL_USE); + assert(!IMAPListParseSelectionOptions("RECURSIVEMATCH", &options)); + assert(!IMAPListParseSelectionOptions("UNKNOWN", &options)); + assert(IMAPListParseReturnOptions("CHILDREN SUBSCRIBED", &options)); + assert(options == (IMAP_LIST_RETURN_CHILDREN | + IMAP_LIST_RETURN_SUBSCRIBED)); + + assert(IMAPListPatternMatches("*", "Archive/2026/July")); + assert(IMAPListPatternMatches("Archive/%", "Archive/2026")); + assert(!IMAPListPatternMatches("Archive/%", "Archive/2026/July")); + assert(IMAPListPatternMatches("A*e/%", "Archive/2026")); + assert(!IMAPListPatternMatches("INBOX", "INBOX/Child")); + + count = IMAPListSplitPatterns(group, patterns, 4); + assert(count == 3); + assert(strcmp(patterns[0], "INBOX/%") == 0); + assert(strcmp(patterns[1], "Archive/*") == 0); + assert(strcmp(patterns[2], "Plain") == 0); + return 0; +}