001/*
002 *  Copyright 2017 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.plugins.zimbra;
017
018import java.io.IOException;
019import java.io.StringReader;
020import java.io.UnsupportedEncodingException;
021import java.net.SocketTimeoutException;
022import java.net.UnknownHostException;
023import java.nio.charset.StandardCharsets;
024import java.security.InvalidKeyException;
025import java.security.NoSuchAlgorithmException;
026import java.time.Duration;
027import java.time.Instant;
028import java.time.ZonedDateTime;
029import java.util.ArrayList;
030import java.util.Collections;
031import java.util.Date;
032import java.util.List;
033import java.util.Map;
034import java.util.TreeMap;
035import java.util.stream.Collectors;
036
037import javax.crypto.Mac;
038import javax.crypto.spec.SecretKeySpec;
039
040import org.apache.avalon.framework.activity.Disposable;
041import org.apache.avalon.framework.service.ServiceException;
042import org.apache.avalon.framework.service.ServiceManager;
043import org.apache.cocoon.ProcessingException;
044import org.apache.cocoon.environment.Redirector;
045import org.apache.commons.codec.binary.Hex;
046import org.apache.commons.lang3.StringUtils;
047import org.apache.excalibur.xml.dom.DOMParser;
048import org.apache.excalibur.xml.xpath.PrefixResolver;
049import org.apache.excalibur.xml.xpath.XPathProcessor;
050import org.apache.http.HeaderElement;
051import org.apache.http.HeaderElementIterator;
052import org.apache.http.HttpResponse;
053import org.apache.http.NameValuePair;
054import org.apache.http.client.config.RequestConfig;
055import org.apache.http.client.methods.CloseableHttpResponse;
056import org.apache.http.client.methods.HttpGet;
057import org.apache.http.client.methods.HttpPost;
058import org.apache.http.client.protocol.HttpClientContext;
059import org.apache.http.client.utils.URLEncodedUtils;
060import org.apache.http.conn.ConnectionKeepAliveStrategy;
061import org.apache.http.conn.ConnectionPoolTimeoutException;
062import org.apache.http.cookie.Cookie;
063import org.apache.http.entity.StringEntity;
064import org.apache.http.impl.client.CloseableHttpClient;
065import org.apache.http.impl.client.HttpClients;
066import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
067import org.apache.http.message.BasicHeaderElementIterator;
068import org.apache.http.message.BasicNameValuePair;
069import org.apache.http.protocol.HTTP;
070import org.apache.http.protocol.HttpContext;
071import org.apache.http.util.EntityUtils;
072import org.w3c.dom.Document;
073import org.w3c.dom.Element;
074import org.w3c.dom.Node;
075import org.w3c.dom.NodeList;
076import org.xml.sax.InputSource;
077import org.xml.sax.SAXException;
078
079import org.ametys.core.cache.AbstractCacheManager;
080import org.ametys.core.cache.Cache;
081import org.ametys.core.user.User;
082import org.ametys.core.user.UserIdentity;
083import org.ametys.core.user.UserManager;
084import org.ametys.core.util.JSONUtils;
085import org.ametys.plugins.messagingconnector.AbstractMessagingConnector;
086import org.ametys.plugins.messagingconnector.CalendarEvent;
087import org.ametys.plugins.messagingconnector.EmailMessage;
088import org.ametys.plugins.messagingconnector.MessagingConnectorException;
089import org.ametys.runtime.config.Config;
090import org.ametys.runtime.i18n.I18nizableText;
091
092/**
093 * The connector used by the messaging connector plugin when the zimbra mail
094 * server is used. Implements the methods of the MessagingConnector interface in
095 * order to get the informations from the mail server
096 */
097public class ZimbraConnector extends AbstractMessagingConnector implements Disposable
098{
099    /** token cache id */
100    public static final String TOKEN_CACHE = ZimbraConnector.class.getName() + "$token";
101
102    /** The number of seconds after what kept alive connections are dropt */
103    protected static final int _DROP_KEPTALIVE_CONNECTION_AFTER = 5;
104    
105    private static final ZimbraPrefixResolver __PREFIX_RESOLVER = new ZimbraPrefixResolver();
106    
107    /** The user manager */
108    protected UserManager _usersManager;
109
110    /** The JSON Utils */
111    protected JSONUtils _jsonUtils;
112
113    /** Url to zimbra */
114    protected String _zimbraUrl;
115
116    /** Preauth secret key */
117    protected String _domainPreauthSecretKey;
118
119    /** Request to the remote app will be ppoled for perfs purposes */
120    protected PoolingHttpClientConnectionManager _connectionManager;
121    
122    /** The keep-alive stragegy to optimize http clients */
123    protected ConnectionKeepAliveStrategy _connectionKeepAliveStrategy;
124    
125    /** The shared configuration for request (for timeout purposes) */
126    protected RequestConfig _connectionConfig;
127    
128    private XPathProcessor _xPathProcessor;
129    private DOMParser _domParser;
130
131    @Override
132    public void service(ServiceManager smanager) throws ServiceException
133    {
134        super.service(smanager);
135        _usersManager = (UserManager) smanager.lookup(UserManager.ROLE);
136        _jsonUtils = (JSONUtils) smanager.lookup(JSONUtils.ROLE);
137        _cacheManager = (AbstractCacheManager) smanager.lookup(AbstractCacheManager.ROLE);
138        _xPathProcessor = (XPathProcessor) smanager.lookup(XPathProcessor.ROLE);
139        _domParser = (DOMParser) smanager.lookup(DOMParser.ROLE);
140    }
141
142    @Override
143    public void initialize()
144    {
145        super.initialize();
146        _zimbraUrl = StringUtils.removeEnd(Config.getInstance().getValue("zimbra.config.zimbra.baseUrl"), "/");
147        _domainPreauthSecretKey = Config.getInstance().getValue("zimbra.config.preauth.key");
148        
149        int maxSimultaneousConnections = (int) (long) Config.getInstance().getValue("zimbra.config.maxconnections");
150        // Same value for 3 timeouts (socket, connection and response) so one connection can last 3 times this value
151        int connectionTimeout = (int) Math.max(0, (long) Config.getInstance().getValue("zimbra.config.timeout"));  
152        
153        // A pooling connection manager to avoid flooding remote server by reusing existing connections AND by limiting their number
154        _connectionManager = new PoolingHttpClientConnectionManager();
155        _connectionManager.setMaxTotal(maxSimultaneousConnections > 0 ? maxSimultaneousConnections : Integer.MAX_VALUE);
156        _connectionManager.setDefaultMaxPerRoute(maxSimultaneousConnections > 0 ? maxSimultaneousConnections : Integer.MAX_VALUE);
157        
158        // inspired from http://www.baeldung.com/httpclient-connection-management
159        // to keep a connection alive for a few seconds if the remote server did not send back this information
160        _connectionKeepAliveStrategy = new ConnectionKeepAliveStrategy() 
161        {
162            @Override
163            public long getKeepAliveDuration(HttpResponse response, HttpContext context) 
164            {
165                HeaderElementIterator it = new BasicHeaderElementIterator(response.headerIterator(HTTP.CONN_KEEP_ALIVE));
166                while (it.hasNext()) 
167                {
168                    HeaderElement he = it.nextElement();
169                    String param = he.getName();
170                    String value = he.getValue();
171                    if (value != null && param.equalsIgnoreCase("timeout")) 
172                    {
173                        try
174                        {
175                            return Long.parseLong(value) * 1000;
176                        }
177                        catch (NumberFormatException ignore) 
178                        {
179                            // Ignore
180                        }
181                    }
182                }
183                return _DROP_KEPTALIVE_CONNECTION_AFTER * 1000;
184            }
185        };
186        
187        _connectionConfig = RequestConfig.custom()
188                .setConnectTimeout(connectionTimeout * 1000)
189                .setConnectionRequestTimeout(connectionTimeout * 1000) // Time to get an object from the pool
190                .setSocketTimeout(connectionTimeout * 1000).build();
191
192        long tokenDuration = Math.max(0, (long) Config.getInstance().getValue("zimbra.config.tokenDuration")); 
193        
194        _cacheManager.createMemoryCache(TOKEN_CACHE, 
195                new I18nizableText("plugin.zimbra", "PLUGINS_ZIMBRA_CACHE_TOKEN_LABEL"),
196                new I18nizableText("plugin.zimbra", "PLUGINS_ZIMBRA_CACHE_TOKEN_DESCRIPTION"),
197                false,
198                Duration.ofMinutes(tokenDuration));
199    }
200    
201    public void dispose()
202    {
203        _connectionManager.close();
204    }
205    
206    /**
207     * Get a new pooled http client. Do not forget to close it.
208     * @return The client
209     */
210    protected CloseableHttpClient _getHttpClient()
211    {
212        return HttpClients.custom()
213                .setConnectionManager(_connectionManager)
214                .setConnectionManagerShared(true) // avoid automatic pool closing
215                .setKeepAliveStrategy(_connectionKeepAliveStrategy)
216                .setDefaultRequestConfig(_connectionConfig)
217               .build();
218    }
219
220    /**
221     * Preauth user and redirect to the zimbra application
222     * @param redirector The redirector
223     * @param targetApp The zimbra application (ex: mail) 
224     * @throws ProcessingException if failed to redirect
225     * @throws IOException if failed to redirect
226     */
227    public void redirect(Redirector redirector, String targetApp) throws ProcessingException, IOException
228    {
229        UserIdentity identity = _currentUserProvider.getUser();
230        User user = _usersManager.getUser(identity);
231        
232        if (user != null)
233        {
234            String qs = _computeQueryString(user, targetApp);
235            if (qs != null)
236            {
237                redirector.redirect(false, _zimbraUrl + "/service/preauth?" + qs);
238            }
239        }
240    }
241    
242
243    /**
244     * Zimbra preauth request to log the current user into zimbra and retrieve the ZM_AUTH_TOKEN
245     * @param userIdentity The user for which the preauth request will be done.
246     * @return The Zimbra ZM_AUTH_TOKEN which can be used in future request made through the Zimbra REST API or <code>null</code> if user is null or has no email.
247     * @throws MessagingConnectorException if failed to get zimbra token for user
248     */
249    protected String _doPreauthRequest(UserIdentity userIdentity)
250    {
251        User user = _usersManager.getUser(userIdentity);
252        // Computing query string
253        String qs = _computeQueryString(user, null);
254        if (qs == null)
255        {
256            return null;
257        }
258        
259        HttpGet get = new HttpGet(_zimbraUrl + "/service/preauth?" + qs);
260
261        if (getLogger().isDebugEnabled())
262        {
263            getLogger().debug("ZimbraConnector: performing preauth request for user {}", user.getIdentity());
264        }
265        
266        try (CloseableHttpClient httpClient = _getHttpClient())
267        {
268            HttpClientContext context = HttpClientContext.create();
269            try (CloseableHttpResponse response = httpClient.execute(get, context))
270            {
271                List<Cookie> cookies = context.getCookieStore().getCookies();
272    
273                for (Cookie cookie : cookies)
274                {
275                    if (StringUtils.equals(cookie.getName(), "ZM_AUTH_TOKEN"))
276                    {
277                        return cookie.getValue();
278                    }
279                }
280    
281                throw new MessagingConnectorException("Zimbra authentification failed for user " + user.getEmail(), MessagingConnectorException.ExceptionType.CONFIGURATION_EXCEPTION);
282            }
283        }
284        catch (UnknownHostException e)
285        {
286            throw new MessagingConnectorException("Unknown host for zimbra server. Giving up to proceed to the Zimbra preauth action for user " + user, MessagingConnectorException.ExceptionType.CONFIGURATION_EXCEPTION, e);
287        }
288        catch (ConnectionPoolTimeoutException | SocketTimeoutException e)
289        {
290            throw new MessagingConnectorException("There are already too many connections to zimbra server. Giving up to proceed to the Zimbra preauth action for user " + user, MessagingConnectorException.ExceptionType.TIMEOUT, e);
291        }
292        catch (IOException e)
293        {
294            throw new MessagingConnectorException("Unable to proceed to the Zimbra preauth action for user : " + user.getEmail(), MessagingConnectorException.ExceptionType.UNKNOWN, e);
295        }
296    }
297    
298    private String _computeQueryString(User user, String targetApp)
299    {
300        if (user == null)
301        {
302            return null;
303        }
304
305        String zimbraUser = user.getEmail();
306
307        if (StringUtils.isEmpty(zimbraUser))
308        {
309            if (getLogger().isDebugEnabled())
310            {
311                getLogger().debug("Cannot retreive zimbra information with empty email for user " + user);
312            }
313            return null;
314        }
315
316        String timestamp = String.valueOf(System.currentTimeMillis());
317        String computedPreauth = null;
318
319        try
320        {
321            computedPreauth = _getComputedPreauth(zimbraUser, timestamp, _domainPreauthSecretKey);
322        }
323        catch (Exception e)
324        {
325            throw new MessagingConnectorException("Unable to compute the preauth key during the Zimbra preauth action for user : " + zimbraUser, MessagingConnectorException.ExceptionType.UNKNOWN, e);
326        }
327
328        // Preauth request parameters
329        List<NameValuePair> params = new ArrayList<>();
330        params.add(new BasicNameValuePair("account", zimbraUser));
331        params.add(new BasicNameValuePair("timestamp", timestamp));
332        params.add(new BasicNameValuePair("expires", "0"));
333        params.add(new BasicNameValuePair("preauth", computedPreauth));
334        if (targetApp != null)
335        {
336            params.add(new BasicNameValuePair("redirectURL", "/?app=" + targetApp));
337        }
338        
339        // Computing query string
340        return URLEncodedUtils.format(params, StandardCharsets.UTF_8);
341    }
342
343    /**
344     * Compute the preauth key.
345     * @param zimbraUser The Zimbra User
346     * @param timestamp The timestamp
347     * @param secretKey The secret key
348     * @return The computed preauth key
349     * @throws NoSuchAlgorithmException if no Provider supports a MacSpi
350     *             implementation for the specified algorithm (HmacSHA1).
351     * @throws InvalidKeyException if the given key is inappropriate for
352     *             initializing the MAC
353     * @throws UnsupportedEncodingException If the named charset (UTF-8) is not
354     *             supported
355     */
356    protected String _getComputedPreauth(String zimbraUser, String timestamp, String secretKey) throws NoSuchAlgorithmException, InvalidKeyException, UnsupportedEncodingException
357    {
358        SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes(), "HmacSHA1");
359        Mac mac = Mac.getInstance("HmacSHA1");
360        mac.init(signingKey);
361
362        String data = StringUtils.join(new String[] {zimbraUser, "name", "0", timestamp}, '|');
363        byte[] rawHmac = mac.doFinal(data.getBytes());
364        byte[] hexBytes = new Hex().encode(rawHmac);
365        return new String(hexBytes, "UTF-8");
366    }
367
368    @Override
369    protected List<CalendarEvent> internalGetEvents(UserIdentity userIdentity, int maxDays, int maxEvents) throws MessagingConnectorException
370    {
371        Document response = _getSoapEvents(userIdentity, maxDays);
372        
373        if (response == null)
374        {
375            return Collections.EMPTY_LIST;
376        }
377        
378        NodeList events = _xPathProcessor.selectNodeList(response, "/soap:Envelope/soap:Body/mail:SearchResponse/mail:appt/mail:inst", __PREFIX_RESOLVER);
379        int count = 0;
380        Map<Long, CalendarEvent> calendarEvents = new TreeMap<>();
381        while (count < events.getLength())
382        {
383            Element item = (Element) events.item(count);
384            
385            String rawStart = _getAttribute(item, "s");
386            long longStart = Long.parseLong(rawStart);
387            Instant startInst = Instant.ofEpochMilli(longStart);
388            Date start = Date.from(startInst);
389            
390            String duration = _getAttribute(item, "dur");
391            Instant endInst = startInst.plusMillis(Long.parseLong(duration));
392            Date end = Date.from(endInst);
393            
394            String name = _getAttribute(item, "name");
395            String location = _getAttribute(item, "loc");
396            
397            CalendarEvent newEvent = new CalendarEvent();
398            newEvent.setStartDate(start);
399            newEvent.setEndDate(end);
400            newEvent.setSubject(name);
401            newEvent.setLocation(location);
402            calendarEvents.put(longStart, newEvent);
403            
404            count++;
405        }
406        
407        return calendarEvents.entrySet().stream().limit(maxEvents).map(e -> e.getValue()).collect(Collectors.toList());
408    }
409
410    private String _getAttribute(Element item, String name)
411    {
412        String value = item.getAttribute(name);
413        if (StringUtils.isEmpty(value))
414        {
415            value = ((Element) item.getParentNode()).getAttribute(name);
416        }
417        
418        return value;
419    }
420
421    @Override
422    protected int internalGetEventsCount(UserIdentity userIdentity, int maxDays) throws MessagingConnectorException
423    {
424        Document response = _getSoapEvents(userIdentity, maxDays);
425        
426        if (response == null)
427        {
428            return 0;
429        }
430        
431        return _xPathProcessor.evaluateAsNumber(response, "count(/soap:Envelope/soap:Body/mail:SearchResponse/mail:appt/mail:inst)", __PREFIX_RESOLVER).intValue();
432    }
433    
434    private Document _getSoapEvents(UserIdentity userIdentity, int maxDays)
435    {
436        // Connection to the zimbra mail server
437        String authToken = _getToken(userIdentity);
438        
439        if (StringUtils.isEmpty(authToken))
440        {
441            return null;
442        }
443        
444        ZonedDateTime now = ZonedDateTime.now();
445        String soapRequest = "<soap:Envelope xmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\">" 
446                           + "  <soap:Header>"
447                           + "    <context xmlns=\"urn:zimbra\">"
448                           + "       <authToken>" + authToken + "</authToken>" 
449                           + "    </context>"
450                           + "  </soap:Header>"
451                           + "  <soap:Body>"
452                           + "   <SearchRequest xmlns=\"urn:zimbraMail\" types=\"appointment\" calExpandInstStart=\"" + now.toInstant().toEpochMilli() + "\" calExpandInstEnd =\"" + now.plusDays(maxDays).toInstant().toEpochMilli() + "\" >"
453                           + "     <query>in:calendar</query>"
454                           + "   </SearchRequest>" 
455                           + "  </soap:Body>" 
456                           + "</soap:Envelope>";
457
458        HttpPost req = new HttpPost(_zimbraUrl + "/service/soap");
459        req.setEntity(new StringEntity(soapRequest, StandardCharsets.UTF_8));
460        
461        try (CloseableHttpClient httpClient = _getHttpClient(); CloseableHttpResponse response = httpClient.execute(req, HttpClientContext.create()))
462        {
463            String content = EntityUtils.toString(response.getEntity());
464        
465            Document document = _domParser.parseDocument(new InputSource(new StringReader(content)));
466         
467            String failure = _xPathProcessor.evaluateAsString(document, "/soap:Envelope/soap:Body/soap:Fault/soap:Reason/soap:Text", __PREFIX_RESOLVER);
468         
469            if (StringUtils.isEmpty(failure))
470            {
471                return document;
472            }
473            else
474            {
475                getLogger().warn("Zimbra failed to return next events for user {}: {}", userIdentity, failure);
476                return null;
477            }
478        }
479        catch (IOException | SAXException e)
480        {
481            throw new MessagingConnectorException("Failed to get Zimbra events for user " + userIdentity, MessagingConnectorException.ExceptionType.UNKNOWN, e);
482        }
483    }
484
485    @Override
486    protected List<EmailMessage> internalGetEmails(UserIdentity userIdentity, int maxEmails) throws MessagingConnectorException
487    {
488        // Connection to the zimbra mail server
489        String authToken = _getToken(userIdentity);
490        
491        if (StringUtils.isEmpty(authToken))
492        {
493            return Collections.EMPTY_LIST;
494        }
495        
496        String soapRequest = "<soap:Envelope xmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\">"
497                           + "  <soap:Header>"
498                           + "    <context xmlns=\"urn:zimbra\">"
499                           + "       <authToken>" + authToken + "</authToken>"
500                           + "    </context>"
501                           + "  </soap:Header>"
502                           + "  <soap:Body>"
503                           + "   <SearchRequest xmlns=\"urn:zimbraMail\" types=\"message\" limit=\"" + maxEmails + "\">"
504                           + "        <query>is:unread in:inbox</query>"
505                           + "    </SearchRequest>"
506                           + "  </soap:Body>"
507                           + "</soap:Envelope>";
508
509        HttpPost req = new HttpPost(_zimbraUrl + "/service/soap");
510        req.setEntity(new StringEntity(soapRequest, StandardCharsets.UTF_8));
511
512        try (CloseableHttpClient httpClient = _getHttpClient(); CloseableHttpResponse response = httpClient.execute(req, HttpClientContext.create()))
513        {
514            String content = EntityUtils.toString(response.getEntity());
515
516            Document document = _domParser.parseDocument(new InputSource(new StringReader(content)));
517            
518            String failure = _xPathProcessor.evaluateAsString(document, "/soap:Envelope/soap:Body/soap:Fault/soap:Reason/soap:Text", __PREFIX_RESOLVER);
519            
520            if (StringUtils.isEmpty(failure))
521            {
522                NodeList emails = _xPathProcessor.selectNodeList(document, "/soap:Envelope/soap:Body/mail:SearchResponse/mail:m", __PREFIX_RESOLVER);
523                
524                List<EmailMessage> result = new ArrayList<>();
525                int count = 0;
526                while (count < emails.getLength())
527                {
528                    Node item = emails.item(count);
529         
530                    EmailMessage email = new EmailMessage();
531                    email.setSender(_xPathProcessor.evaluateAsString(item, "mail:e/@a", __PREFIX_RESOLVER));
532                    email.setSubject(_xPathProcessor.evaluateAsString(item, "mail:su", __PREFIX_RESOLVER));
533                    email.setSummary(_xPathProcessor.evaluateAsString(item, "mail:fr", __PREFIX_RESOLVER));
534                    
535                    result.add(email);
536         
537                    count++;
538                }
539                
540                return result;
541            }
542            else
543            {
544                getLogger().warn("Zimbra failed to return unread email for user {}: {}", userIdentity, failure);
545                return Collections.emptyList();
546            }
547        }
548        catch (IOException | SAXException e)
549        {
550            throw new MessagingConnectorException("Failed to get Zimbra email count for user " + userIdentity, MessagingConnectorException.ExceptionType.UNKNOWN, e);
551        }
552    }
553
554    @Override
555    protected int internalGetEmailsCount(UserIdentity userIdentity) throws MessagingConnectorException
556    {
557        // Connection to the zimbra mail server
558        String authToken = _getToken(userIdentity);
559        
560        if (StringUtils.isEmpty(authToken))
561        {
562            return 0;
563        }
564        
565        String soapRequest = "<soap:Envelope xmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\">"
566                          +  "  <soap:Header>"
567                          +  "    <context xmlns=\"urn:zimbra\">"
568                          +  "       <authToken>" + authToken + "</authToken>"
569                          +  "    </context>"
570                          +  "  </soap:Header>"
571                          +  "  <soap:Body>"
572                          +  "   <GetFolderRequest xmlns=\"urn:zimbraMail\" depth=\"0\">"
573                          +  "        <folder path=\"inbox\" />"
574                          +  "    </GetFolderRequest>"
575                          +  "  </soap:Body>"
576                          +  "</soap:Envelope>";
577        
578        HttpPost req = new HttpPost(_zimbraUrl + "/service/soap");
579        req.setEntity(new StringEntity(soapRequest, StandardCharsets.UTF_8));
580        
581        try (CloseableHttpClient httpClient = _getHttpClient(); CloseableHttpResponse response = httpClient.execute(req, HttpClientContext.create()))
582        {
583            String content = EntityUtils.toString(response.getEntity());
584            
585            Document document = _domParser.parseDocument(new InputSource(new StringReader(content)));
586            
587            String failure = _xPathProcessor.evaluateAsString(document, "/soap:Envelope/soap:Body/soap:Fault/soap:Reason/soap:Text", __PREFIX_RESOLVER);
588            
589            if (StringUtils.isEmpty(failure))
590            {
591                return _xPathProcessor.evaluateAsNumber(document, "/soap:Envelope/soap:Body/mail:GetFolderResponse/mail:folder/@u", __PREFIX_RESOLVER).intValue();
592            }
593            else
594            {
595                getLogger().warn("Zimbra failed to return unread email count for user {}: {}", userIdentity, failure);
596                return 0;
597            }
598        }
599        catch (IOException | SAXException e)
600        {
601            throw new MessagingConnectorException("Failed to get Zimbra email count for user " + userIdentity, MessagingConnectorException.ExceptionType.UNKNOWN, e);
602        }
603    }
604    
605    private static class ZimbraPrefixResolver implements PrefixResolver
606    {
607        private Map<String, String> _namespaces = Map.of("soap", "http://www.w3.org/2003/05/soap-envelope",
608                                                         "mail", "urn:zimbraMail");
609        
610        public String prefixToNamespace(String prefix)
611        {
612            return _namespaces.get(prefix);
613        }
614    }
615
616    private String _getToken (UserIdentity user)
617    {
618        if (user == null)
619        {
620            return null;
621        }
622         
623        Cache<UserIdentity, String> cache = _getCache();
624        
625        return cache.get(user, key -> _doPreauthRequest(key));
626    }
627    
628    private Cache<UserIdentity, String> _getCache() 
629    {
630        return this._cacheManager.get(TOKEN_CACHE);
631    }    
632}