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.nio.charset.StandardCharsets;
021import java.time.Duration;
022import java.time.Instant;
023import java.time.ZonedDateTime;
024import java.util.ArrayList;
025import java.util.Collections;
026import java.util.Date;
027import java.util.List;
028import java.util.Map;
029import java.util.TreeMap;
030import java.util.stream.Collectors;
031
032import org.apache.avalon.framework.activity.Disposable;
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.cocoon.ProcessingException;
036import org.apache.cocoon.environment.Redirector;
037import org.apache.commons.lang3.StringUtils;
038import org.apache.excalibur.xml.dom.DOMParser;
039import org.apache.excalibur.xml.xpath.PrefixResolver;
040import org.apache.excalibur.xml.xpath.XPathProcessor;
041import org.apache.http.Header;
042import org.apache.http.HeaderElement;
043import org.apache.http.HeaderElementIterator;
044import org.apache.http.HttpResponse;
045import org.apache.http.client.config.RequestConfig;
046import org.apache.http.client.methods.CloseableHttpResponse;
047import org.apache.http.client.methods.HttpPost;
048import org.apache.http.client.protocol.HttpClientContext;
049import org.apache.http.conn.ConnectionKeepAliveStrategy;
050import org.apache.http.entity.StringEntity;
051import org.apache.http.impl.client.CloseableHttpClient;
052import org.apache.http.impl.client.HttpClients;
053import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
054import org.apache.http.message.BasicHeaderElementIterator;
055import org.apache.http.protocol.HTTP;
056import org.apache.http.protocol.HttpContext;
057import org.apache.http.util.EntityUtils;
058import org.w3c.dom.Document;
059import org.w3c.dom.Element;
060import org.w3c.dom.Node;
061import org.w3c.dom.NodeList;
062import org.xml.sax.InputSource;
063import org.xml.sax.SAXException;
064
065import org.ametys.core.cache.AbstractCacheManager;
066import org.ametys.core.cache.Cache;
067import org.ametys.core.user.User;
068import org.ametys.core.user.UserIdentity;
069import org.ametys.core.user.UserManager;
070import org.ametys.core.util.JSONUtils;
071import org.ametys.plugins.messagingconnector.AbstractMessagingConnector;
072import org.ametys.plugins.messagingconnector.CalendarEvent;
073import org.ametys.plugins.messagingconnector.EmailMessage;
074import org.ametys.plugins.messagingconnector.MessagingConnectorException;
075import org.ametys.runtime.config.Config;
076import org.ametys.runtime.i18n.I18nizableText;
077
078/**
079 * The connector used by the messaging connector plugin when the zimbra mail
080 * server is used. Implements the methods of the MessagingConnector interface in
081 * order to get the informations from the mail server.<br>
082 */
083public class ZimbraConnector extends AbstractMessagingConnector implements Disposable
084{
085    /** token cache id */
086    public static final String TOKEN_CACHE = ZimbraConnector.class.getName() + "$token";
087
088    /** The number of seconds after what kept alive connections are dropt */
089    protected static final int _DROP_KEPTALIVE_CONNECTION_AFTER = 5;
090    
091    private static final ZimbraPrefixResolver __PREFIX_RESOLVER = new ZimbraPrefixResolver();
092    
093    /** The user manager */
094    protected UserManager _usersManager;
095
096    /** The JSON Utils */
097    protected JSONUtils _jsonUtils;
098
099    /** Url to zimbra */
100    protected String _zimbraUrl;
101
102    /** Preauth secret key */
103    protected String _domainPreauthSecretKey;
104
105    /** Request to the remote app will be ppoled for perfs purposes */
106    protected PoolingHttpClientConnectionManager _connectionManager;
107    
108    /** The keep-alive stragegy to optimize http clients */
109    protected ConnectionKeepAliveStrategy _connectionKeepAliveStrategy;
110    
111    /** The shared configuration for request (for timeout purposes) */
112    protected RequestConfig _connectionConfig;
113    
114    private XPathProcessor _xPathProcessor;
115    private DOMParser _domParser;
116
117    @Override
118    public void service(ServiceManager smanager) throws ServiceException
119    {
120        super.service(smanager);
121        _usersManager = (UserManager) smanager.lookup(UserManager.ROLE);
122        _jsonUtils = (JSONUtils) smanager.lookup(JSONUtils.ROLE);
123        _cacheManager = (AbstractCacheManager) smanager.lookup(AbstractCacheManager.ROLE);
124        _xPathProcessor = (XPathProcessor) smanager.lookup(XPathProcessor.ROLE);
125        _domParser = (DOMParser) smanager.lookup(DOMParser.ROLE);
126    }
127
128    @Override
129    public void initialize()
130    {
131        super.initialize();
132        _zimbraUrl = StringUtils.removeEnd(Config.getInstance().getValue("zimbra.config.zimbra.baseUrl"), "/");
133        _domainPreauthSecretKey = Config.getInstance().getValue("zimbra.config.preauth.key");
134        
135        int maxSimultaneousConnections = (int) (long) Config.getInstance().getValue("zimbra.config.maxconnections");
136        // Same value for 3 timeouts (socket, connection and response) so one connection can last 3 times this value
137        int connectionTimeout = (int) Math.max(0, (long) Config.getInstance().getValue("zimbra.config.timeout"));  
138        
139        // A pooling connection manager to avoid flooding remote server by reusing existing connections AND by limiting their number
140        _connectionManager = new PoolingHttpClientConnectionManager();
141        _connectionManager.setMaxTotal(maxSimultaneousConnections > 0 ? maxSimultaneousConnections : Integer.MAX_VALUE);
142        _connectionManager.setDefaultMaxPerRoute(maxSimultaneousConnections > 0 ? maxSimultaneousConnections : Integer.MAX_VALUE);
143        
144        // inspired from http://www.baeldung.com/httpclient-connection-management
145        // to keep a connection alive for a few seconds if the remote server did not send back this information
146        _connectionKeepAliveStrategy = new ConnectionKeepAliveStrategy() 
147        {
148            @Override
149            public long getKeepAliveDuration(HttpResponse response, HttpContext context) 
150            {
151                HeaderElementIterator it = new BasicHeaderElementIterator(response.headerIterator(HTTP.CONN_KEEP_ALIVE));
152                while (it.hasNext()) 
153                {
154                    HeaderElement he = it.nextElement();
155                    String param = he.getName();
156                    String value = he.getValue();
157                    if (value != null && param.equalsIgnoreCase("timeout")) 
158                    {
159                        try
160                        {
161                            return Long.parseLong(value) * 1000;
162                        }
163                        catch (NumberFormatException ignore) 
164                        {
165                            // Ignore
166                        }
167                    }
168                }
169                return _DROP_KEPTALIVE_CONNECTION_AFTER * 1000;
170            }
171        };
172        
173        _connectionConfig = RequestConfig.custom()
174                .setConnectTimeout(connectionTimeout * 1000)
175                .setConnectionRequestTimeout(connectionTimeout * 1000) // Time to get an object from the pool
176                .setSocketTimeout(connectionTimeout * 1000).build();
177
178        long tokenDuration = Math.max(0, (long) Config.getInstance().getValue("zimbra.config.tokenDuration")); 
179        
180        _cacheManager.createMemoryCache(TOKEN_CACHE, 
181                new I18nizableText("plugin.zimbra", "PLUGINS_ZIMBRA_CACHE_TOKEN_LABEL"),
182                new I18nizableText("plugin.zimbra", "PLUGINS_ZIMBRA_CACHE_TOKEN_DESCRIPTION"),
183                false,
184                Duration.ofMinutes(tokenDuration));
185    }
186    
187    public void dispose()
188    {
189        _connectionManager.close();
190    }
191    
192    /**
193     * Get a new pooled http client. Do not forget to close it.
194     * @return The client
195     */
196    protected CloseableHttpClient _getHttpClient()
197    {
198        return HttpClients.custom()
199                .setConnectionManager(_connectionManager)
200                .setConnectionManagerShared(true) // avoid automatic pool closing
201                .setKeepAliveStrategy(_connectionKeepAliveStrategy)
202                .setDefaultRequestConfig(_connectionConfig)
203                .build();
204    }
205
206    /**
207     * Preauth user and redirect to the zimbra application
208     * @param redirector The redirector
209     * @param targetApp The zimbra application (ex: mail) 
210     * @throws ProcessingException if failed to redirect
211     * @throws IOException if failed to redirect
212     */
213    public void redirect(Redirector redirector, String targetApp) throws ProcessingException, IOException
214    {
215        UserIdentity identity = _currentUserProvider.getUser();
216        User user = _usersManager.getUser(identity);
217        
218        if (user != null)
219        {
220            String qs = _computeQueryString(user, targetApp);
221            if (qs != null)
222            {
223                redirector.redirect(false, _zimbraUrl + "/service/preauth?" + qs);
224            }
225        }
226    }
227    
228    /**
229     * Zimbra preauth request to log the current user into zimbra and retrieve the ZM_AUTH_TOKEN
230     * @param userIdentity The user for which the preauth request will be done.
231     * @return The Zimbra 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.
232     * @throws MessagingConnectorException if failed to get zimbra token for user
233     */
234    protected String _doPreauthRequest(UserIdentity userIdentity)
235    {
236        User user = _usersManager.getUser(userIdentity);
237        if (user == null)
238        {
239            return null;
240        }
241        
242        String zimbraUser = user.getEmail();
243        
244        if (StringUtils.isEmpty(zimbraUser))
245        {
246            if (getLogger().isDebugEnabled())
247            {
248                getLogger().debug("Cannot retreive zimbra information with empty email for user " + user);
249            }
250            
251            return null;
252        }
253
254        if (getLogger().isDebugEnabled())
255        {
256            getLogger().debug("ZimbraConnector: performing preauth request for user {}", user.getIdentity());
257        }
258        
259        return ZimbraPreauthHelper.doPreauthRequest(_zimbraUrl, zimbraUser, _domainPreauthSecretKey, _getHttpClient());
260    }
261    
262    private String _computeQueryString(User user, String targetApp)
263    {
264        if (user == null)
265        {
266            return null;
267        }
268
269        String zimbraUser = user.getEmail();
270
271        if (StringUtils.isEmpty(zimbraUser))
272        {
273            if (getLogger().isDebugEnabled())
274            {
275                getLogger().debug("Cannot retreive zimbra information with empty email for user " + user);
276            }
277            
278            return null;
279        }
280        
281        return ZimbraPreauthHelper._computeQueryString(zimbraUser, _domainPreauthSecretKey, targetApp);
282    }
283    
284    @Override
285    protected List<CalendarEvent> internalGetEvents(UserIdentity userIdentity, int maxDays, int maxEvents) throws MessagingConnectorException
286    {
287        Document response = _getSoapEvents(userIdentity, maxDays);
288        
289        if (response == null)
290        {
291            return Collections.EMPTY_LIST;
292        }
293        
294        NodeList events = _xPathProcessor.selectNodeList(response, "/soap:Envelope/soap:Body/mail:SearchResponse/mail:appt/mail:inst", __PREFIX_RESOLVER);
295        int count = 0;
296        Map<Long, CalendarEvent> calendarEvents = new TreeMap<>();
297        while (count < events.getLength())
298        {
299            Element item = (Element) events.item(count);
300            
301            String rawStart = _getAttribute(item, "s");
302            long longStart = Long.parseLong(rawStart);
303            Instant startInst = Instant.ofEpochMilli(longStart);
304            Date start = Date.from(startInst);
305            
306            String duration = _getAttribute(item, "dur");
307            Instant endInst = startInst.plusMillis(Long.parseLong(duration));
308            Date end = Date.from(endInst);
309            
310            String name = _getAttribute(item, "name");
311            String location = _getAttribute(item, "loc");
312            
313            CalendarEvent newEvent = new CalendarEvent();
314            newEvent.setStartDate(start);
315            newEvent.setEndDate(end);
316            newEvent.setSubject(name);
317            newEvent.setLocation(location);
318            calendarEvents.put(longStart, newEvent);
319            
320            count++;
321        }
322        
323        return calendarEvents.entrySet().stream().limit(maxEvents).map(e -> e.getValue()).collect(Collectors.toList());
324    }
325
326    private String _getAttribute(Element item, String name)
327    {
328        String value = item.getAttribute(name);
329        if (StringUtils.isEmpty(value))
330        {
331            value = ((Element) item.getParentNode()).getAttribute(name);
332        }
333        
334        return value;
335    }
336
337    @Override
338    protected int internalGetEventsCount(UserIdentity userIdentity, int maxDays) throws MessagingConnectorException
339    {
340        Document response = _getSoapEvents(userIdentity, maxDays);
341        
342        if (response == null)
343        {
344            return 0;
345        }
346        
347        return _xPathProcessor.evaluateAsNumber(response, "count(/soap:Envelope/soap:Body/mail:SearchResponse/mail:appt/mail:inst)", __PREFIX_RESOLVER).intValue();
348    }
349    
350    private Document _getSoapEvents(UserIdentity userIdentity, int maxDays) throws MessagingConnectorException
351    {
352        ZonedDateTime now = ZonedDateTime.now();
353        String soapRequestBody = "<SearchRequest xmlns=\"urn:zimbraMail\" types=\"appointment\" calExpandInstStart=\"" + now.toInstant().toEpochMilli() + "\" calExpandInstEnd =\"" + now.plusDays(maxDays).toInstant().toEpochMilli() + "\" >"
354                               + "  <query>in:calendar</query>"
355                               + "</SearchRequest>";
356
357        return _executeSoapRequest(userIdentity, soapRequestBody, "next events");
358    }
359
360    @Override
361    protected List<EmailMessage> internalGetEmails(UserIdentity userIdentity, int maxEmails) throws MessagingConnectorException
362    {
363        String soapRequestBody = "<SearchRequest xmlns=\"urn:zimbraMail\" types=\"message\" limit=\"" + maxEmails + "\">"
364                               + "  <query>is:unread in:inbox</query>"
365                               + "</SearchRequest>";
366
367        Document document = _executeSoapRequest(userIdentity, soapRequestBody, "unread email");
368        
369        if (document != null)
370        {
371            NodeList emails = _xPathProcessor.selectNodeList(document, "/soap:Envelope/soap:Body/mail:SearchResponse/mail:m", __PREFIX_RESOLVER);
372            
373            List<EmailMessage> result = new ArrayList<>();
374            int count = 0;
375            while (count < emails.getLength())
376            {
377                Node item = emails.item(count);
378     
379                EmailMessage email = new EmailMessage();
380                email.setSender(_xPathProcessor.evaluateAsString(item, "mail:e/@a", __PREFIX_RESOLVER));
381                email.setSubject(_xPathProcessor.evaluateAsString(item, "mail:su", __PREFIX_RESOLVER));
382                email.setSummary(_xPathProcessor.evaluateAsString(item, "mail:fr", __PREFIX_RESOLVER));
383                
384                result.add(email);
385     
386                count++;
387            }
388            
389            return result;
390        }
391        
392        return Collections.emptyList();
393    }
394
395    @Override
396    protected int internalGetEmailsCount(UserIdentity userIdentity) throws MessagingConnectorException
397    {
398        String soapRequestBody = "<GetFolderRequest xmlns=\"urn:zimbraMail\" depth=\"0\">"
399                               + "  <folder path=\"inbox\" />"
400                               + "</GetFolderRequest>";
401        
402        Document document = _executeSoapRequest(userIdentity, soapRequestBody, "email count");
403        if (document != null)
404        {
405            return _xPathProcessor.evaluateAsNumber(document, "/soap:Envelope/soap:Body/mail:GetFolderResponse/mail:folder/@u", __PREFIX_RESOLVER).intValue();
406        }
407        
408        return 0;
409    }
410    
411    private Document _executeSoapRequest(UserIdentity userIdentity, String soapRequestBody, String shortDescription) throws MessagingConnectorException
412    {
413        // Connection to the zimbra mail server
414        String authToken = _getToken(userIdentity);
415        
416        if (StringUtils.isEmpty(authToken))
417        {
418            return null;
419        }
420        
421        String soapRequest = "<soap:Envelope xmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\">" 
422                + "  <soap:Header>"
423                + "    <context xmlns=\"urn:zimbra\">"
424                + "       <authToken>" + authToken + "</authToken>" 
425                + "    </context>"
426                + "  </soap:Header>"
427                + "  <soap:Body>"
428                +      soapRequestBody
429                + "  </soap:Body>" 
430                + "</soap:Envelope>";
431        
432        HttpPost req = new HttpPost(_zimbraUrl + "/service/soap");
433        req.setEntity(new StringEntity(soapRequest, StandardCharsets.UTF_8));
434        
435        try (
436                CloseableHttpClient httpClient = _getHttpClient();
437                CloseableHttpResponse response = httpClient.execute(req, HttpClientContext.create())
438            )
439        {
440            int status = response.getStatusLine().getStatusCode();
441            if (status != 200)
442            {
443                getLogger().error("Zimbra failed on calling '{}' with status {} and reason: {}", shortDescription, status, response.getStatusLine().getReasonPhrase());
444                if (getLogger().isDebugEnabled())
445                {
446                    StringBuilder sb = new StringBuilder("Headers:");
447                    for (Header header : response.getAllHeaders())
448                    {
449                        sb.append("\n - ").append(header.getName()).append(": ").append(header.getValue());
450                    }
451                    getLogger().debug(sb.toString());
452                }
453                return null;
454            }
455            
456            String content = EntityUtils.toString(response.getEntity());
457            if (getLogger().isDebugEnabled())
458            {
459                getLogger().debug("{}: {}", userIdentity, content);
460            }
461            Document document = _domParser.parseDocument(new InputSource(new StringReader(content)));
462            
463            String failure = _xPathProcessor.evaluateAsString(document, "/soap:Envelope/soap:Body/soap:Fault/soap:Reason/soap:Text", __PREFIX_RESOLVER);
464            
465            if (StringUtils.isEmpty(failure))
466            {
467                return document;
468            }
469            else
470            {
471                getLogger().warn("Zimbra failed to return {} for user {}: {}", shortDescription, userIdentity, failure);
472                return null;
473            }
474        }
475        catch (IOException | SAXException e)
476        {
477            throw new MessagingConnectorException("Failed to get Zimbra " + shortDescription + " for user " + userIdentity, MessagingConnectorException.ExceptionType.UNKNOWN, e);
478        }
479    }
480    
481    private static class ZimbraPrefixResolver implements PrefixResolver
482    {
483        private Map<String, String> _namespaces = Map.of("soap", "http://www.w3.org/2003/05/soap-envelope",
484                                                         "mail", "urn:zimbraMail");
485        
486        public String prefixToNamespace(String prefix)
487        {
488            return _namespaces.get(prefix);
489        }
490    }
491
492    private String _getToken (UserIdentity user)
493    {
494        if (user == null)
495        {
496            return null;
497        }
498         
499        Cache<UserIdentity, String> cache = _getCache();
500        
501        return cache.get(user, key -> _doPreauthRequest(key));
502    }
503    
504    private Cache<UserIdentity, String> _getCache() 
505    {
506        return this._cacheManager.get(TOKEN_CACHE);
507    }    
508}