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 String _getToken (UserIdentity user) 482 { 483 if (user == null) 484 { 485 return null; 486 } 487 488 Cache<UserIdentity, String> cache = _getCache(); 489 490 return cache.get(user, key -> _doPreauthRequest(key)); 491 } 492 493 private Cache<UserIdentity, String> _getCache() 494 { 495 return this._cacheManager.get(TOKEN_CACHE); 496 } 497 498 private static final class ZimbraPrefixResolver implements PrefixResolver 499 { 500 private Map<String, String> _namespaces = Map.of("soap", "http://www.w3.org/2003/05/soap-envelope", 501 "mail", "urn:zimbraMail"); 502 503 public String prefixToNamespace(String prefix) 504 { 505 return _namespaces.get(prefix); 506 } 507 } 508}