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&end=p30d&auth=qp&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}