From 0faf2497dbec85bb68fb62b4d8222ae0e32eb784 Mon Sep 17 00:00:00 2001 From: mguessan Date: Thu, 6 May 2010 19:26:44 +0000 Subject: [PATCH] Caldav: Implement basic Carddav search requests git-svn-id: http://svn.code.sf.net/p/davmail/code/trunk@1035 3d1905a2-6b24-0410-a738-b14d5a86fcbd --- src/java/davmail/caldav/CaldavConnection.java | 83 +++-- .../davmail/exchange/ExchangeSession.java | 296 ++++++++++++++---- src/java/davmailmessages.properties | 3 +- 3 files changed, 292 insertions(+), 90 deletions(-) diff --git a/src/java/davmail/caldav/CaldavConnection.java b/src/java/davmail/caldav/CaldavConnection.java index 4926dc87..5c2a638d 100644 --- a/src/java/davmail/caldav/CaldavConnection.java +++ b/src/java/davmail/caldav/CaldavConnection.java @@ -273,6 +273,10 @@ public class CaldavConnection extends AbstractConnection { if (request.path.endsWith("/")) { // GET request on a folder => build ics content of all folder events String folderPath = request.getExchangeFolderPath(); + ExchangeSession.Folder folder = session.getFolder(folderPath); + if (folder.isContact()) { + sendHttpResponse(HttpStatus.SC_OK, buildEtagHeader(session.getFolderResourceTag(folderPath)), "text/vcard", (byte[])null, true); + } else { List events = session.getAllEvents(folderPath); ChunkedResponse response = new ChunkedResponse(HttpStatus.SC_OK, "text/calendar;charset=UTF-8"); response.append("BEGIN:VCALENDAR\r\n"); @@ -281,12 +285,12 @@ public class CaldavConnection extends AbstractConnection { response.append("METHOD:PUBLISH\r\n"); for (ExchangeSession.Event event : events) { - String icsContent = StringUtil.getToken(event.getICS(), "BEGIN:VTIMEZONE", "END:VCALENDAR"); + String icsContent = StringUtil.getToken(event.getBody(), "BEGIN:VTIMEZONE", "END:VCALENDAR"); if (icsContent != null) { response.append("BEGIN:VTIMEZONE"); response.append(icsContent); } else { - icsContent = StringUtil.getToken(event.getICS(), "BEGIN:VEVENT", "END:VCALENDAR"); + icsContent = StringUtil.getToken(event.getBody(), "BEGIN:VEVENT", "END:VCALENDAR"); if (icsContent != null) { response.append("BEGIN:VEVENT"); response.append(icsContent); @@ -295,14 +299,15 @@ public class CaldavConnection extends AbstractConnection { } response.append("END:VCALENDAR"); response.close(); + } } else { - ExchangeSession.Event event = session.getEvent(request.getExchangeFolderPath(), lastPath); - sendHttpResponse(HttpStatus.SC_OK, buildEtagHeader(event.getEtag()), "text/calendar;charset=UTF-8", event.getICS(), true); + ExchangeSession.Item item = session.getItem(request.getExchangeFolderPath(), lastPath); + sendHttpResponse(HttpStatus.SC_OK, buildEtagHeader(item.getEtag()), item.getContentType(), item.getBody(), true); } } else if (request.isHead()) { // test event - ExchangeSession.Event event = session.getEvent(request.getExchangeFolderPath(), lastPath); - sendHttpResponse(HttpStatus.SC_OK, buildEtagHeader(event.getEtag()), "text/calendar;charset=UTF-8", (byte[]) null, true); + ExchangeSession.Item item = session.getItem(request.getExchangeFolderPath(), lastPath); + sendHttpResponse(HttpStatus.SC_OK, buildEtagHeader(item.getEtag()), item.getContentType(), (byte[]) null, true); } else { sendUnsupported(request); } @@ -319,40 +324,54 @@ public class CaldavConnection extends AbstractConnection { } } + private void appendContactsResponses(CaldavResponse response, CaldavRequest request, List contacts) throws IOException { + int size = contacts.size(); + int count = 0; + for (ExchangeSession.Contact contact : contacts) { + DavGatewayTray.debug(new BundleMessage("LOG_LISTING_CONTACT", ++count, size)); + DavGatewayTray.switchIcon(); + appendItemResponse(response, request, contact); + } + } + protected void appendEventsResponses(CaldavResponse response, CaldavRequest request, List events) throws IOException { int size = events.size(); int count = 0; for (ExchangeSession.Event event : events) { DavGatewayTray.debug(new BundleMessage("LOG_LISTING_EVENT", ++count, size)); DavGatewayTray.switchIcon(); - appendEventResponse(response, request, event); + appendItemResponse(response, request, event); } } - protected void appendEventResponse(CaldavResponse response, CaldavRequest request, ExchangeSession.Event event) throws IOException { + protected void appendItemResponse(CaldavResponse response, CaldavRequest request, ExchangeSession.Item item) throws IOException { StringBuilder eventPath = new StringBuilder(); eventPath.append(URIUtil.encodePath(request.getPath())); if (!(eventPath.charAt(eventPath.length() - 1) == '/')) { eventPath.append('/'); } - String eventName = StringUtil.xmlEncode(event.getName()); - eventPath.append(URIUtil.encodeWithinQuery(eventName)); + String itemName = StringUtil.xmlEncode(item.getName()); + eventPath.append(URIUtil.encodeWithinQuery(itemName)); response.startResponse(eventPath.toString()); response.startPropstat(); - if (request.hasProperty("calendar-data")) { - response.appendCalendarData(event.getICS()); + if (request.hasProperty("calendar-data") && item instanceof ExchangeSession.Event) { + response.appendCalendarData(item.getBody()); } if (request.hasProperty("getcontenttype")) { - response.appendProperty("D:getcontenttype", "text/calendar; component=vevent"); + if (item instanceof ExchangeSession.Event) { + response.appendProperty("D:getcontenttype", "text/calendar; component=vevent"); + } else if (item instanceof ExchangeSession.Contact) { + response.appendProperty("D:getcontenttype", "text/vcard"); + } } if (request.hasProperty("getetag")) { - response.appendProperty("D:getetag", event.getEtag()); + response.appendProperty("D:getetag", item.getEtag()); } if (request.hasProperty("resourcetype")) { response.appendProperty("D:resourcetype"); } if (request.hasProperty("displayname")) { - response.appendProperty("D:displayname", eventName); + response.appendProperty("D:displayname", itemName); } response.endPropStatOK(); response.endResponse(); @@ -365,8 +384,9 @@ public class CaldavConnection extends AbstractConnection { * @param request Caldav request * @param subFolder calendar folder path relative to request path * @throws IOException on error + * @return Exchange folder object */ - public void appendFolder(CaldavResponse response, CaldavRequest request, String subFolder) throws IOException { + public ExchangeSession.Folder appendFolder(CaldavResponse response, CaldavRequest request, String subFolder) throws IOException { ExchangeSession.Folder folder = session.getFolder(request.getExchangeFolderPath(subFolder)); response.startResponse(URIUtil.encodePath(request.getPath(subFolder))); @@ -420,6 +440,7 @@ public class CaldavConnection extends AbstractConnection { response.endPropStatOK(); response.endResponse(); + return folder; } /** @@ -560,17 +581,21 @@ public class CaldavConnection extends AbstractConnection { String folderPath = request.getExchangeFolderPath(); CaldavResponse response = new CaldavResponse(HttpStatus.SC_MULTI_STATUS); response.startMultistatus(); - appendFolder(response, request, null); + ExchangeSession.Folder folder = appendFolder(response, request, null); if (request.getDepth() == 1) { - DavGatewayTray.debug(new BundleMessage("LOG_SEARCHING_CALENDAR_EVENTS", folderPath)); - List events = session.getAllEvents(folderPath); - DavGatewayTray.debug(new BundleMessage("LOG_FOUND_CALENDAR_EVENTS", events.size())); - appendEventsResponses(response, request, events); - // Send sub folders for multi-calendar support under iCal, except for public folders - if (!folderPath.startsWith("/public")) { - List folderList = session.getSubCalendarFolders(folderPath, false); - for (ExchangeSession.Folder folder : folderList) { - appendFolder(response, request, folder.folderPath.substring(folder.folderPath.indexOf('/') + 1)); + if (folder.isContact()) { + appendContactsResponses(response, request, session.getAllContacts(folderPath)); + } else { + DavGatewayTray.debug(new BundleMessage("LOG_SEARCHING_CALENDAR_EVENTS", folderPath)); + List events = session.getAllEvents(folderPath); + DavGatewayTray.debug(new BundleMessage("LOG_FOUND_CALENDAR_EVENTS", events.size())); + appendEventsResponses(response, request, events); + // Send sub folders for multi-calendar support under iCal, except for public folders + if (!folderPath.startsWith("/public")) { + List folderList = session.getSubCalendarFolders(folderPath, false); + for (ExchangeSession.Folder subFolder : folderList) { + appendFolder(response, request, subFolder.folderPath.substring(subFolder.folderPath.indexOf('/') + 1)); + } } } } @@ -630,9 +655,9 @@ public class CaldavConnection extends AbstractConnection { // ignore cases for Sunbird if (eventName != null && eventName.length() > 0 && !"inbox".equals(eventName) && !"calendar".equals(eventName)) { - ExchangeSession.Event event; - event = session.getEvent(folderPath, eventName); - appendEventResponse(response, request, event); + ExchangeSession.Item item; + item = session.getItem(folderPath, eventName); + appendItemResponse(response, request, item); } } catch (HttpException e) { DavGatewayTray.warn(new BundleMessage("LOG_EVENT_NOT_AVAILABLE", eventName, href)); diff --git a/src/java/davmail/exchange/ExchangeSession.java b/src/java/davmail/exchange/ExchangeSession.java index 85b95e28..20916e39 100644 --- a/src/java/davmail/exchange/ExchangeSession.java +++ b/src/java/davmail/exchange/ExchangeSession.java @@ -106,12 +106,15 @@ public class ExchangeSession { protected static final Namespace URN_SCHEMAS_HTTPMAIL = Namespace.getNamespace("urn:schemas:httpmail:"); protected static final Namespace SCHEMAS_EXCHANGE = Namespace.getNamespace("http://schemas.microsoft.com/exchange/"); protected static final Namespace SCHEMAS_MAPI_PROPTAG = Namespace.getNamespace("http://schemas.microsoft.com/mapi/proptag/"); + protected static final Namespace URN_SCHEMAS_CONTACTS = Namespace.getNamespace("urn:schemas:contacts:"); + protected static final DavPropertyNameSet EVENT_REQUEST_PROPERTIES = new DavPropertyNameSet(); static { EVENT_REQUEST_PROPERTIES.add(DavPropertyName.create("permanenturl", SCHEMAS_EXCHANGE)); EVENT_REQUEST_PROPERTIES.add(DavPropertyName.GETETAG); + EVENT_REQUEST_PROPERTIES.add(DavPropertyName.create("contentclass", Namespace.getNamespace("DAV:"))); } protected static final DavPropertyNameSet WELL_KNOWN_FOLDERS = new DavPropertyNameSet(); @@ -605,6 +608,15 @@ public class ExchangeSession { } } + protected String getPropertyIfExists(DavPropertySet properties, DavPropertyName davPropertyName, String defaultValue) { + String value = getPropertyIfExists(properties, davPropertyName); + if (value == null) { + return defaultValue; + } else { + return value; + } + } + protected String getPropertyIfExists(DavPropertySet properties, DavPropertyName davPropertyName) { DavProperty property = properties.get(davPropertyName); if (property == null) { @@ -1876,20 +1888,181 @@ public class ExchangeSession { } /** - * Calendar event object + * Generic folder item. */ - public class Event { + public abstract class Item { protected String href; protected String permanentUrl; protected String displayName; protected String etag; protected String contentClass; protected String noneMatch; + + /** + * Create empty Item. + */ + public Item() { + } + + /** + * Return item content type + * @return content type + */ + public abstract String getContentType(); + + /** + * Retrieve item body from Exchange + * @return item body + * @throws HttpException on error + */ + public abstract String getBody() throws HttpException; + + /** + * Build Item instance from multistatusResponse info + * @param multiStatusResponse response + * @throws URIException on error + */ + public Item(MultiStatusResponse multiStatusResponse) throws URIException { + href = URIUtil.decode(multiStatusResponse.getHref()); + permanentUrl = getPropertyIfExists(multiStatusResponse.getProperties(HttpStatus.SC_OK), "permanenturl", SCHEMAS_EXCHANGE); + etag = getPropertyIfExists(multiStatusResponse.getProperties(HttpStatus.SC_OK), "getetag", Namespace.getNamespace("DAV:")); + displayName = getPropertyIfExists(multiStatusResponse.getProperties(HttpStatus.SC_OK), "displayname", Namespace.getNamespace("DAV:")); + } + + /** + * Get event name (file name part in URL). + * + * @return event name + */ + public String getName() { + int index = href.lastIndexOf('/'); + if (index >= 0) { + return href.substring(index + 1); + } else { + return href; + } + } + + /** + * Get event etag (last change tag). + * + * @return event etag + */ + public String getEtag() { + return etag; + } + + protected HttpException buildHttpException(Exception e) { + String message = "Unable to get event " + getName() + " at " + permanentUrl + ": " + e.getMessage(); + LOGGER.warn(message); + return new HttpException(message); + } + } + /** + * Calendar event object + */ + public class Contact extends Item { + /** + * Build Contact instance from multistatusResponse info + * @param multiStatusResponse response + * @throws URIException on error + */ + public Contact(MultiStatusResponse multiStatusResponse) throws URIException { + super(multiStatusResponse); + } + + @Override + public String getContentType() { + return "text/vcard"; + } + + @Override + public String getBody() throws HttpException { + // first retrieve contact details + String result = null; + + PropFindMethod propFindMethod = null; + try { + propFindMethod = new PropFindMethod(URIUtil.encodePath(permanentUrl)); + DavGatewayHttpClientFacade.executeHttpMethod(httpClient, propFindMethod); + MultiStatus responses = propFindMethod.getResponseBodyAsMultiStatus(); + if (responses.getResponses().length > 0) { + DavPropertySet properties = responses.getResponses()[0].getProperties(HttpStatus.SC_OK); + + ICSBufferedWriter writer = new ICSBufferedWriter(); + writer.writeLine("BEGIN:VCARD"); + writer.writeLine("VERSION:3.0"); + writer.write("UID:"); + writer.writeLine(URIUtil.encodePath(getName())); + writer.write("FN:"); + writer.writeLine(getPropertyIfExists(properties, DavPropertyName.create("cn", URN_SCHEMAS_CONTACTS), "")); + // RFC 2426: Family Name, Given Name, Additional Names, Honorific Prefixes, and Honorific Suffixes + writer.write("N:"); + writer.write(getPropertyIfExists(properties, DavPropertyName.create("sn", URN_SCHEMAS_CONTACTS), "")); + writer.write(";"); + writer.write(getPropertyIfExists(properties, DavPropertyName.create("givenName", URN_SCHEMAS_CONTACTS), "")); + writer.write(";"); + writer.writeLine(getPropertyIfExists(properties, DavPropertyName.create("middlename", URN_SCHEMAS_CONTACTS), "")); + + writer.write("TEL;TYPE=cell:"); + writer.writeLine(getPropertyIfExists(properties, DavPropertyName.create("mobile", URN_SCHEMAS_CONTACTS), "")); + //writer.writeLine(getPropertyIfExists(properties, DavPropertyName.create("initials", URN_SCHEMAS_CONTACTS), "")); + + // The structured type value corresponds, in sequence, to the post office box; the extended address; + // the street address; the locality (e.g., city); the region (e.g., state or province); + // the postal code; the country name + // ADR;TYPE=dom,home,postal,parcel:;;123 Main Street;Any Town;CA;91921-1234 + writer.write("ADR;TYPE=home:;;"); + writer.write(getPropertyIfExists(properties, DavPropertyName.create("homepostaladdress", URN_SCHEMAS_CONTACTS), "")); + writer.write(";;;"); + writer.newLine(); + writer.writeLine("END:VCARD"); + result = writer.toString(); + + } + } catch (DavException e) { + throw buildHttpException(e); + } catch (IOException e) { + throw buildHttpException(e); + } finally { + if (propFindMethod != null) { + propFindMethod.releaseConnection(); + } + } + return result; + + } + } + + /** + * Calendar event object + */ + public class Event extends Item { /** * ICS content */ protected String icsBody; + /** + * Build Event instance from multistatusResponse info + * @param multiStatusResponse response + * @throws URIException on error + */ + public Event(MultiStatusResponse multiStatusResponse) throws URIException { + super(multiStatusResponse); + } + + /** + * Create empty event. + */ + public Event() { + } + + @Override + public String getContentType() { + return "text/calendar;charset=UTF-8"; + } + protected boolean isCalendarContentType(String contentType) { return contentType.startsWith("text/calendar") || contentType.startsWith("application/ics"); } @@ -1977,7 +2150,8 @@ public class ExchangeSession { * @return ICS (iCalendar) event * @throws HttpException on error */ - public String getICS() throws HttpException { + @Override + public String getBody() throws HttpException { String result; LOGGER.debug("Get event: " + permanentUrl); // try to get PR_INTERNET_CONTENT @@ -2004,35 +2178,6 @@ public class ExchangeSession { return result; } - protected HttpException buildHttpException(Exception e) { - String message = "Unable to get event " + getName() + " at " + permanentUrl + ": " + e.getMessage(); - LOGGER.warn(message); - return new HttpException(message); - } - - /** - * Get event name (file name part in URL). - * - * @return event name - */ - public String getName() { - int index = href.lastIndexOf('/'); - if (index >= 0) { - return href.substring(index + 1); - } else { - return href; - } - } - - /** - * Get event etag (last change tag). - * - * @return event etag - */ - public String getEtag() { - return etag; - } - protected String fixTimezoneId(String line, String validTimezoneId) { return StringUtil.replaceToken(line, "TZID=", ":", validTimezoneId); } @@ -2630,8 +2775,8 @@ public class ExchangeSession { LOGGER.warn("Unable to patch event to trigger activeSync push"); } else { // need to retrieve new etag - Event newEvent = getEvent(href); - eventResult.etag = newEvent.etag; + Item newItem = getItem(href); + eventResult.etag = newItem.etag; } } return eventResult; @@ -2639,6 +2784,38 @@ public class ExchangeSession { } } + /** + * Search contacts in provided folder. + * + * @param folderPath Exchange folder path + * @return list of contacts + * @throws IOException on error + */ + public List getAllContacts(String folderPath) throws IOException { + + String searchQuery = "Select \"DAV:getetag\", \"http://schemas.microsoft.com/exchange/permanenturl\", \"DAV:displayname\"" + + " FROM Scope('SHALLOW TRAVERSAL OF \"" + folderPath + "\"')\n" + + " WHERE \"DAV:contentclass\" = 'urn:content-classes:person'\n"; + return getContacts(folderPath, searchQuery); + } + + /** + * Search contacts in provided folder matching the search query. + * + * @param folderPath Exchange folder path + * @param searchQuery Exchange search query + * @return list of contacts + * @throws IOException on error + */ + protected List getContacts(String folderPath, String searchQuery) throws IOException { + List contacts = new ArrayList(); + MultiStatusResponse[] responses = DavGatewayHttpClientFacade.executeSearchMethod(httpClient, URIUtil.encodePath(folderPath), searchQuery); + for (MultiStatusResponse response : responses) { + contacts.add(new Contact(response)); + } + return contacts; + } + /** * Search calendar messages in provided folder. * @@ -2718,13 +2895,13 @@ public class ExchangeSession { MultiStatusResponse[] responses = DavGatewayHttpClientFacade.executeSearchMethod(httpClient, URIUtil.encodePath(folderPath), searchQuery); for (MultiStatusResponse response : responses) { String instancetype = getPropertyIfExists(response.getProperties(HttpStatus.SC_OK), "instancetype", Namespace.getNamespace("urn:schemas:calendar:")); - Event event = buildEvent(response); + Event event = new Event(response); //noinspection VariableNotUsedInsideIf if (instancetype == null) { // check ics content try { - event.getICS(); - // getICS success => add event or task + event.getBody(); + // getBody success => add event or task events.add(event); } catch (HttpException e) { // invalid event: exclude from list @@ -2738,45 +2915,53 @@ public class ExchangeSession { } /** - * Get event named eventName in folder + * Get item named eventName in folder * * @param folderPath Exchange folder path - * @param eventName event name + * @param itemName event name * @return event object * @throws IOException on error */ - public Event getEvent(String folderPath, String eventName) throws IOException { - String eventPath = folderPath + '/' + eventName; - Event event; + public Item getItem(String folderPath, String itemName) throws IOException { + String itemPath = folderPath + '/' + itemName; + Item item; try { - event = getEvent(eventPath); + item = getItem(itemPath); } catch (HttpNotFoundException hnfe) { // failover for Exchange 2007 plus encoding issue - String decodedEventName = eventName.replaceAll("_xF8FF_", "/").replaceAll("_x003F_", "?").replaceAll("'", "''"); + String decodedEventName = itemName.replaceAll("_xF8FF_", "/").replaceAll("_x003F_", "?").replaceAll("'", "''"); ExchangeSession.MessageList messages = searchMessages(folderPath, " AND \"DAV:displayname\"='" + decodedEventName + '\''); if (!messages.isEmpty()) { - event = getEvent(messages.get(0).getPermanentUrl()); + item = getItem(messages.get(0).getPermanentUrl()); } else { throw hnfe; } } - return event; + return item; } /** - * Get event by url + * Get item by url * - * @param eventPath Event path + * @param itemPath Event path * @return event object * @throws IOException on error */ - public Event getEvent(String eventPath) throws IOException { - MultiStatusResponse[] responses = DavGatewayHttpClientFacade.executePropFindMethod(httpClient, URIUtil.encodePath(eventPath), 0, EVENT_REQUEST_PROPERTIES); + public Item getItem(String itemPath) throws IOException { + MultiStatusResponse[] responses = DavGatewayHttpClientFacade.executePropFindMethod(httpClient, URIUtil.encodePath(itemPath), 0, EVENT_REQUEST_PROPERTIES); if (responses.length == 0) { throw new DavMailException("EXCEPTION_EVENT_NOT_FOUND"); } - return buildEvent(responses[0]); + String contentClass = getPropertyIfExists( responses[0].getProperties(HttpStatus.SC_OK), + "contentclass", Namespace.getNamespace("DAV:")); + if ("urn:content-classes:person".equals(contentClass)) { + return new Contact(responses[0]); + } else if ("urn:content-classes:appointment".equals(contentClass)){ + return new Event(responses[0]); + } else { + throw new DavMailException("EXCEPTION_EVENT_NOT_FOUND"); + } } /** @@ -2804,15 +2989,6 @@ public class ExchangeSession { return status; } - protected Event buildEvent(MultiStatusResponse calendarResponse) throws URIException { - Event event = new Event(); - event.href = URIUtil.decode(calendarResponse.getHref()); - event.permanentUrl = getPropertyIfExists(calendarResponse.getProperties(HttpStatus.SC_OK), "permanenturl", SCHEMAS_EXCHANGE); - event.etag = getPropertyIfExists(calendarResponse.getProperties(HttpStatus.SC_OK), "getetag", Namespace.getNamespace("DAV:")); - event.displayName = getPropertyIfExists(calendarResponse.getProperties(HttpStatus.SC_OK), "displayname", Namespace.getNamespace("DAV:")); - return event; - } - private static int dumpIndex; /** diff --git a/src/java/davmailmessages.properties b/src/java/davmailmessages.properties index be4f0c8a..1b54985e 100644 --- a/src/java/davmailmessages.properties +++ b/src/java/davmailmessages.properties @@ -95,6 +95,7 @@ LOG_LDAP_UNSUPPORTED_FILTER_ATTRIBUTE=Unsupported filter attribute: {0}= {1} LOG_LDAP_UNSUPPORTED_FILTER_VALUE=Unsupported filter value LOG_LDAP_UNSUPPORTED_OPERATION=Unsupported operation: {0} LOG_LISTING_EVENT=Listing event {0}/{1} +LOG_LISTING_CONTACT=Listing event {0}/{1} LOG_MESSAGE={0} LOG_NEW_VERSION_AVAILABLE=A new version ({0}) of DavMail Gateway is available ! LOG_OPEN_LINK_NOT_SUPPORTED=Open link not supported (tried AWT Desktop and SWT Program) @@ -253,4 +254,4 @@ UI_USE_SYSTEM_PROXIES=Use system proxy settings : UI_SHOW_STARTUP_BANNER=Display startup banner UI_SHOW_STARTUP_BANNER_HELP=Whether to show the initial startup notification window or not UI_IMAP_IDLE_DELAY=IDLE folder monitor delay (IMAP): -UI_IMAP_IDLE_DELAY_HELP=IMAP folder idle monitor delay in minutes, leave empty to disable IDLE support \ No newline at end of file +UI_IMAP_IDLE_DELAY_HELP=IMAP folder idle monitor delay in minutes, leave empty to disable IDLE support