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.InputStream;
020import java.io.UnsupportedEncodingException;
021import java.nio.charset.StandardCharsets;
022import java.security.InvalidKeyException;
023import java.security.NoSuchAlgorithmException;
024import java.util.ArrayList;
025import java.util.Collection;
026import java.util.Collections;
027import java.util.Comparator;
028import java.util.Date;
029import java.util.HashMap;
030import java.util.Iterator;
031import java.util.LinkedHashMap;
032import java.util.List;
033import java.util.Map;
034import java.util.SortedMap;
035import java.util.TreeMap;
036import java.util.stream.Collectors;
037
038import javax.crypto.Mac;
039import javax.crypto.spec.SecretKeySpec;
040
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.collections.ListUtils;
047import org.apache.commons.lang.StringUtils;
048import org.apache.http.NameValuePair;
049import org.apache.http.client.methods.CloseableHttpResponse;
050import org.apache.http.client.methods.HttpGet;
051import org.apache.http.client.protocol.HttpClientContext;
052import org.apache.http.client.utils.URLEncodedUtils;
053import org.apache.http.cookie.Cookie;
054import org.apache.http.impl.client.CloseableHttpClient;
055import org.apache.http.impl.client.HttpClients;
056import org.apache.http.message.BasicNameValuePair;
057import org.apache.http.util.EntityUtils;
058
059import org.ametys.core.user.CurrentUserProvider;
060import org.ametys.core.user.User;
061import org.ametys.core.user.UserIdentity;
062import org.ametys.core.user.UserManager;
063import org.ametys.core.util.DateUtils;
064import org.ametys.core.util.JSONUtils;
065import org.ametys.plugins.messagingconnector.AbstractMessagingConnector;
066import org.ametys.plugins.messagingconnector.CalendarEvent;
067import org.ametys.plugins.messagingconnector.EmailMessage;
068import org.ametys.plugins.messagingconnector.MessagingConnectorException;
069import org.ametys.runtime.config.Config;
070
071import net.fortuna.ical4j.data.CalendarBuilder;
072import net.fortuna.ical4j.data.ParserException;
073import net.fortuna.ical4j.model.Calendar;
074import net.fortuna.ical4j.model.Component;
075import net.fortuna.ical4j.model.ComponentList;
076import net.fortuna.ical4j.model.DateTime;
077import net.fortuna.ical4j.model.Period;
078import net.fortuna.ical4j.model.PeriodList;
079import net.fortuna.ical4j.model.component.VEvent;
080import net.fortuna.ical4j.model.property.Location;
081import net.fortuna.ical4j.model.property.Summary;
082
083/**
084 * 
085 * The connector used by the messaging connector plugin when the zimbra mail
086 * server is used. Implements the methods of the MessagingConnector interface in
087 * order to get the informations from the mail server
088 *
089 */
090public class ZimbraConnector extends AbstractMessagingConnector
091{
092    /** The user preferences manager. */
093    protected CurrentUserProvider _currentUserProvider;
094
095    /** The user manager */
096    protected UserManager _usersManager;
097
098    /** The JSON Utils */
099    protected JSONUtils _jsonUtils;
100
101    /** Url to zimbra */
102    protected String _zimbraUrl;
103
104    /** Preauth secret key */
105    protected String _domainPreauthSecretKey;
106
107    @Override
108    public void service(ServiceManager smanager) throws ServiceException
109    {
110        super.service(smanager);
111        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
112        _usersManager = (UserManager) smanager.lookup(UserManager.ROLE);
113        _jsonUtils = (JSONUtils) smanager.lookup(JSONUtils.ROLE);
114    }
115
116    @Override
117    public void initialize()
118    {
119        super.initialize();
120        _zimbraUrl = StringUtils.removeEnd(Config.getInstance().getValueAsString("zimbra.config.zimbra.baseUrl"), "/");
121        _domainPreauthSecretKey = Config.getInstance().getValueAsString("zimbra.config.preauth.key");
122    }
123
124    /**
125     * Preauth user and redirect to the zimbra application
126     * @param redirector The redirector
127     * @param targetApp The zimbra application (ex: mail) 
128     * @throws ProcessingException if failed to redirect
129     * @throws IOException if failed to redirect
130     */
131    public void redirect(Redirector redirector, String targetApp) throws ProcessingException, IOException
132    {
133        UserIdentity identity = _currentUserProvider.getUser();
134        User user = _usersManager.getUser(identity);
135        
136        if (user != null)
137        {
138            String qs = _computeQueryString(user, targetApp);
139            if (qs != null)
140            {
141                redirector.redirect(false, _zimbraUrl + "/service/preauth?" + qs);
142            }
143        }
144    }
145    
146    /**
147     * Retrieves some mail info through a query via the Zimbra REST API. Query
148     * used is :
149     * home/~/inbox?query=is:unread&fmt=json&auth=qp&zauthtoken=##TOKEN##
150     * @param user The user
151     * @param zmAuthToken The auth token corresponding to a currenlty logged
152     *            user in zimbra.
153     * @return Map of info returned by the REST request
154     */
155    protected Map<String, Object> _getEmailInfo(User user, String zmAuthToken)
156    {
157        // Preauth request parameters
158        List<NameValuePair> params = new ArrayList<>();
159        params.add(new BasicNameValuePair("fmt", "json")); // Request json format Restrict to unread messages from the inbox folder.
160        params.add(new BasicNameValuePair("query", "is:unread")); // Restrict to unread messages from the inbox folder.
161        params.add(new BasicNameValuePair("auth", "qp")); // Requires authentication token method
162        params.add(new BasicNameValuePair("zauthtoken", zmAuthToken)); // Authentication token
163
164        // Computing query string
165        String qs = URLEncodedUtils.format(params, StandardCharsets.UTF_8);
166
167        // Rest API URL. '~' stands for the current active user, which is
168        // retrieved through the auth token b/c auth token method is used (see
169        // query string params)
170        HttpGet get = new HttpGet(_zimbraUrl + "/home/~/inbox?" + qs);
171
172        CloseableHttpClient httpClient = HttpClients.createDefault();
173        HttpClientContext context = HttpClientContext.create();
174        try (CloseableHttpResponse response = httpClient.execute(get, context))
175        {
176            String content = EntityUtils.toString(response.getEntity());
177            return _jsonUtils.convertJsonToMap(content);
178        }
179        catch (IOException e)
180        {
181            throw new MessagingConnectorException("Failed to get Zimbra emails for user " + user, e);
182        }
183    }
184
185    /**
186     * Retrieves a calendar through a query via the Zimbra REST API.
187     * Query used is :
188     * home/~/calendar?fmt=ics&amp;end=p30d&amp;auth=qp&amp;zauthtoken=##TOKEN##
189     * @param user The user
190     * @param zmAuthToken The auth token corresponding to a currenlty logged
191     *            user in zimbra.
192     * @param dayInterval Interval of day to search for the next event
193     * @return The Calendar
194     */
195    protected Calendar _getCalendar(User user, String zmAuthToken, String dayInterval)
196    {
197        // Preauth request parameters
198        String relativeEnd = "p" + dayInterval + "d";
199        List<NameValuePair> params = new ArrayList<>();
200        params.add(new BasicNameValuePair("fmt", "ics")); // Request iCAL format
201        params.add(new BasicNameValuePair("start", "p0mi")); // Interval starts from now
202        params.add(new BasicNameValuePair("end", relativeEnd)); // and ends after the configured number of days.
203        params.add(new BasicNameValuePair("auth", "qp")); // Requires authentication token method
204        params.add(new BasicNameValuePair("zauthtoken", zmAuthToken)); // Authentication token
205
206        // Computing query string
207        String qs = URLEncodedUtils.format(params, StandardCharsets.UTF_8);
208
209        // Rest API URL. '~' stands for the current active user, which is
210        // retrieved through the auth token b/c auth token method is used
211        // (see query string params)
212        HttpGet get = new HttpGet(_zimbraUrl + "/home/~/calendar?" + qs);
213
214        CloseableHttpClient httpClient = HttpClients.createDefault();
215        HttpClientContext context = HttpClientContext.create();
216        try (CloseableHttpResponse response = httpClient.execute(get, context); InputStream responseIs = response.getEntity().getContent())
217        {
218            // Construct the iCAL calendar from the response input stream.
219            CalendarBuilder builder = new CalendarBuilder();
220            Calendar calendar = builder.build(responseIs);
221            return calendar;
222        }
223        catch (ParserException | IOException e)
224        {
225            throw new MessagingConnectorException("Failed to get Zimbra calendar events for user " + user, e);
226        }
227    }
228
229    /**
230     * Extract interesting properties for the calendar. Retrieves the next
231     * events and return some info.
232     * @param calendar The calendar
233     * @param dayInterval Interval of day to search for the next event
234     * @param maxEvents The maximum number of events
235     * @return map of info about the next events.
236     */
237    protected List<Map<String, Object>> _extractCalendarProperties(Calendar calendar, int dayInterval, int maxEvents)
238    {
239        // Retrieving events.
240        ComponentList components = calendar.getComponents(Component.VEVENT);
241        List<VEvent> events = ListUtils.typedList(components, VEvent.class);
242
243        // Iterates over all events to find the closest date from now (taking
244        // recurring events into account). Constructs a sorted map where keys
245        // are next closest dates for each event, and values are the
246        // corresponding events.
247        SortedMap<Date, VEvent> eventMap = new TreeMap<>(new Comparator<Date>()
248        {
249            @Override
250            public int compare(Date d1, Date d2)
251            {
252                return d1.compareTo(d2);
253            }
254        });
255
256        java.util.Calendar cal = java.util.Calendar.getInstance();
257        DateTime start = new DateTime(cal.getTime());
258        cal.add(java.util.Calendar.DAY_OF_MONTH, dayInterval);
259        DateTime end = new DateTime(cal.getTime());
260
261        for (VEvent event : events)
262        {
263            PeriodList consumedTime = event.getConsumedTime(start, end);
264            Iterator periodIterator = consumedTime.iterator();
265            Date min = null;
266
267            while (periodIterator.hasNext())
268            {
269                Period period = (Period) periodIterator.next();
270                Date pStart = period.getStart();
271                if (min == null || pStart.before(min))
272                {
273                    min = pStart;
274                }
275            }
276
277            if (min != null)
278            {
279                eventMap.put(min, event);
280            }
281        }
282
283        return eventMap.entrySet().stream().limit(maxEvents).map(e -> _extractCalendarProperties(e.getValue(), e.getKey())).collect(Collectors.toList());
284    }
285
286    /**
287     * Extract interesting properties for the event.
288     * @param event The event
289     * @param date The event start date to display (necessary for reccurent
290     *            event)
291     * @return map of interesting info about this event.
292     */
293    protected Map<String, Object> _extractCalendarProperties(VEvent event, Date date)
294    {
295        Map<String, Object> properties = new HashMap<>();
296
297        if (event != null && date != null)
298        {
299            properties.put("eventStartDateRaw", event.getStartDate().getDate());
300
301            properties.put("eventEndDate", event.getEndDate().getDate());
302
303            // Location
304            Location location = event.getLocation();
305            if (location != null)
306            {
307                String eventLocation = _sanitizeEventLocation(location);
308                properties.put("eventLocation", eventLocation);
309            }
310
311            // Summary
312            Summary summary = event.getSummary();
313            if (summary != null)
314            {
315                properties.put("eventSubject", summary.getValue());
316            }
317        }
318        else
319        {
320            properties.put("eventNothing", true);
321        }
322
323        return properties;
324    }
325
326    /**
327     * Extract the interesting part of the raw event location.
328     * @param location The iCal4j location object.
329     * @return A String representing the sanitized location.
330     */
331    protected String _sanitizeEventLocation(Location location)
332    {
333        String extractedValue = location.getValue();
334
335        if (StringUtils.contains(extractedValue, ';'))
336        {
337            extractedValue = StringUtils.substringBefore(extractedValue, ";");
338        }
339
340        String candidateValue = StringUtils.substringBetween(extractedValue, "\"");
341        if (StringUtils.isNotBlank(candidateValue))
342        {
343            extractedValue = candidateValue;
344        }
345
346        return extractedValue;
347    }
348
349    /**
350     * Zimbra preauth request to log the current user into zimbra and retrieve the ZM_AUTH_TOKEN
351     * @param user The user for which the preauth request will be done.
352     * @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.
353     * @throws MessagingConnectorException if failed to get zimbra token for user
354     */
355    protected String _doPreauthRequest(User user)
356    {
357        // Computing query string
358        String qs = _computeQueryString(user, null);
359        if (qs == null)
360        {
361            return null;
362        }
363        
364        HttpGet get = new HttpGet(_zimbraUrl + "/service/preauth?" + qs);
365
366        CloseableHttpClient httpClient = HttpClients.createDefault();
367        HttpClientContext context = HttpClientContext.create();
368        try (CloseableHttpResponse response = httpClient.execute(get, context))
369        {
370            List<Cookie> cookies = context.getCookieStore().getCookies();
371
372            for (Cookie cookie : cookies)
373            {
374                if (StringUtils.equals(cookie.getName(), "ZM_AUTH_TOKEN"))
375                {
376                    return cookie.getValue();
377                }
378            }
379
380            throw new MessagingConnectorException("Zimbra authentification failed for user " + user.getEmail());
381        }
382        catch (IOException e)
383        {
384            throw new MessagingConnectorException("Unable to proceed to the Zimbra preauth action for user : " + user.getEmail(), e);
385        }
386    }
387    
388    private String _computeQueryString(User user, String targetApp)
389    {
390        if (user == null)
391        {
392            return null;
393        }
394
395        String zimbraUser = user.getEmail();
396
397        if (StringUtils.isEmpty(zimbraUser))
398        {
399            if (getLogger().isDebugEnabled())
400            {
401                getLogger().debug("Cannot retreive zimbra information with empty email for user " + user);
402            }
403            return null;
404        }
405
406        String timestamp = String.valueOf(System.currentTimeMillis());
407        String computedPreauth = null;
408
409        try
410        {
411            computedPreauth = _getComputedPreauth(zimbraUser, timestamp, _domainPreauthSecretKey);
412        }
413        catch (Exception e)
414        {
415            throw new MessagingConnectorException("Unable to compute the preauth key during the Zimbra preauth action for user : " + zimbraUser, e);
416        }
417
418        // Preauth request parameters
419        List<NameValuePair> params = new ArrayList<>();
420        params.add(new BasicNameValuePair("account", zimbraUser));
421        params.add(new BasicNameValuePair("timestamp", timestamp));
422        params.add(new BasicNameValuePair("expires", "0"));
423        params.add(new BasicNameValuePair("preauth", computedPreauth));
424        if (targetApp != null)
425        {
426            params.add(new BasicNameValuePair("redirectURL", "/?app=" + targetApp));
427        }
428        
429        // Computing query string
430        return URLEncodedUtils.format(params, StandardCharsets.UTF_8);
431    }
432
433    /**
434     * Compute the preauth key.
435     * @param zimbraUser The Zimbra User
436     * @param timestamp The timestamp
437     * @param secretKey The secret key
438     * @return The computed preauth key
439     * @throws NoSuchAlgorithmException if no Provider supports a MacSpi
440     *             implementation for the specified algorithm (HmacSHA1).
441     * @throws InvalidKeyException if the given key is inappropriate for
442     *             initializing the MAC
443     * @throws UnsupportedEncodingException If the named charset (UTF-8) is not
444     *             supported
445     */
446    protected String _getComputedPreauth(String zimbraUser, String timestamp, String secretKey) throws NoSuchAlgorithmException, InvalidKeyException, UnsupportedEncodingException
447    {
448        SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes(), "HmacSHA1");
449        Mac mac = Mac.getInstance("HmacSHA1");
450        mac.init(signingKey);
451
452        String data = StringUtils.join(new String[] {zimbraUser, "name", "0", timestamp}, '|');
453        byte[] rawHmac = mac.doFinal(data.getBytes());
454        byte[] hexBytes = new Hex().encode(rawHmac);
455        return new String(hexBytes, "UTF-8");
456    }
457
458    @Override
459    protected List<CalendarEvent> internalGetEvents(UserIdentity userIdentity, Date fromDate, Date untilDate, int maxEvents) throws MessagingConnectorException
460    {
461        // Connection to the zimbra mail server
462        User user = _usersManager.getUser(userIdentity);
463        String authToken = _doPreauthRequest(user);
464        
465        if (StringUtils.isEmpty(authToken))
466        {
467            return Collections.EMPTY_LIST;
468        }
469        
470        List<CalendarEvent> calendarEvent = new ArrayList<>();
471        // Collect the list of events
472        int maxDays = (int) DateUtils.asLocalDate(untilDate).toEpochDay() - (int) DateUtils.asLocalDate(fromDate).toEpochDay();
473        Calendar calendar = _getCalendar(user, authToken, String.valueOf(maxDays));
474        List<Map<String, Object>> vevents = _extractCalendarProperties(calendar, maxDays, maxEvents);
475        for (Map<String, Object> vevent : vevents)
476        {
477            // Creating the CalendarEvent
478            CalendarEvent newEvent = new CalendarEvent();
479            newEvent.setStartDate((Date) vevent.get("eventStartDateRaw"));
480            newEvent.setEndDate((Date) vevent.get("eventEndDate"));
481            newEvent.setSubject((String) vevent.get("eventSubject"));
482            newEvent.setLocation((String) vevent.get("eventLocation"));
483            calendarEvent.add(newEvent);
484        }
485        return calendarEvent;
486    }
487
488    @Override
489    protected int internalGetEventsCount(UserIdentity userIdentity, Date fromDate, Date untilDate) throws MessagingConnectorException
490    {
491        // Connection to the zimbra mail server
492        User user = _usersManager.getUser(userIdentity);
493        String authToken = _doPreauthRequest(user);
494        
495        if (StringUtils.isEmpty(authToken))
496        {
497            return 0;
498        }
499        
500        int maxDays = (int) DateUtils.asLocalDate(untilDate).toEpochDay() - (int) DateUtils.asLocalDate(fromDate).toEpochDay();
501        return _getCalendar(user, authToken, String.valueOf(maxDays)).getComponents(Component.VEVENT).size();
502    }
503
504    @SuppressWarnings("unchecked")
505    @Override
506    protected List<EmailMessage> internalGetEmails(UserIdentity userIdentity, int maxEmails) throws MessagingConnectorException
507    {
508        // Connection to the zimbra mail server
509        User user = _usersManager.getUser(userIdentity);
510        String authToken = _doPreauthRequest(user);
511        
512        if (StringUtils.isEmpty(authToken))
513        {
514            return Collections.EMPTY_LIST;
515        }
516        
517        Map<String, Object> mailInfo = _getEmailInfo(user, authToken);
518
519        List<EmailMessage> listMail = new ArrayList<>();
520
521        if (mailInfo != null && !mailInfo.isEmpty())
522        {
523            if (mailInfo.values().iterator().next() instanceof Collection)
524            {
525                Collection<LinkedHashMap<String, Object>> mailObject = (Collection<LinkedHashMap<String, Object>>) mailInfo.values().iterator().next();
526                for (LinkedHashMap<String, Object> mailObjectMap : mailObject)
527                {
528                    if (listMail.size() < maxEmails)
529                    {
530                        EmailMessage newMail = new EmailMessage();
531                        if (mailObjectMap.get("e") instanceof List)
532                        {
533                            List<LinkedHashMap<String, Object>> mailObjectPerson = (List<LinkedHashMap<String, Object>>) mailObjectMap.get("e");
534                            newMail.setSender(mailObjectPerson.get(1).get("a").toString()); // get the sender
535                        }
536                        newMail.setSubject(mailObjectMap.get("su").toString()); // get the subject
537                        newMail.setSummary(mailObjectMap.get("fr").toString()); // get the summary
538                        listMail.add(newMail);
539                    }
540                }
541            }
542        }
543        else if (getLogger().isWarnEnabled())
544        {
545            getLogger().warn("Zimbra returned empty information for user " + userIdentity);
546        }
547        return listMail;
548    }
549
550    @SuppressWarnings("unchecked")
551    @Override
552    protected int internalGetEmailsCount(UserIdentity userIdentity) throws MessagingConnectorException
553    {
554        // Connection to the zimbra mail server
555        User user = _usersManager.getUser(userIdentity);
556        String authToken = _doPreauthRequest(user);
557        
558        if (StringUtils.isEmpty(authToken))
559        {
560            return 0;
561        }
562        
563        Map<String, Object> mailInfo = _getEmailInfo(user, authToken);
564        if (mailInfo != null && !mailInfo.isEmpty())
565        {
566            return ((Collection<LinkedHashMap<String, Object>>) mailInfo.values().iterator().next()).size();
567        }
568        
569        if (getLogger().isWarnEnabled())
570        {
571            getLogger().warn("Zimbra returned empty information for user " + userIdentity);
572        }
573        
574        return 0;
575    }
576}