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 private String _doPreauthRequest(UserIdentity userIdentity) 229 { 230 User user = _usersManager.getUser(userIdentity); 231 if (user == null) 232 { 233 return null; 234 } 235 236 String zimbraUser = user.getEmail(); 237 238 if (StringUtils.isEmpty(zimbraUser)) 239 { 240 if (getLogger().isDebugEnabled()) 241 { 242 getLogger().debug("Cannot retreive zimbra information with empty email for user " + user); 243 } 244 245 return null; 246 } 247 248 if (getLogger().isDebugEnabled()) 249 { 250 getLogger().debug("ZimbraConnector: performing preauth request for user {}", user.getIdentity()); 251 } 252 253 return ZimbraPreauthHelper._doPreauthRequest(_zimbraUrl, zimbraUser, _domainPreauthSecretKey, _getHttpClient()); 254 } 255 256 private String _computeQueryString(User user, String targetApp) 257 { 258 if (user == null) 259 { 260 return null; 261 } 262 263 String zimbraUser = user.getEmail(); 264 265 if (StringUtils.isEmpty(zimbraUser)) 266 { 267 if (getLogger().isDebugEnabled()) 268 { 269 getLogger().debug("Cannot retreive zimbra information with empty email for user " + user); 270 } 271 272 return null; 273 } 274 275 return ZimbraPreauthHelper._computeQueryString(zimbraUser, _domainPreauthSecretKey, targetApp); 276 } 277 278 @Override 279 protected List<CalendarEvent> internalGetEvents(UserIdentity userIdentity, int maxDays, int maxEvents) throws MessagingConnectorException 280 { 281 Document response = _getSoapEvents(userIdentity, maxDays); 282 283 if (response == null) 284 { 285 return Collections.EMPTY_LIST; 286 } 287 288 NodeList events = _xPathProcessor.selectNodeList(response, "/soap:Envelope/soap:Body/mail:SearchResponse/mail:appt/mail:inst", __PREFIX_RESOLVER); 289 int count = 0; 290 Map<Long, CalendarEvent> calendarEvents = new TreeMap<>(); 291 while (count < events.getLength()) 292 { 293 Element item = (Element) events.item(count); 294 295 String rawStart = _getAttribute(item, "s"); 296 long longStart = Long.parseLong(rawStart); 297 Instant startInst = Instant.ofEpochMilli(longStart); 298 Date start = Date.from(startInst); 299 300 String duration = _getAttribute(item, "dur"); 301 Instant endInst = startInst.plusMillis(Long.parseLong(duration)); 302 Date end = Date.from(endInst); 303 304 String name = _getAttribute(item, "name"); 305 String location = _getAttribute(item, "loc"); 306 307 CalendarEvent newEvent = new CalendarEvent(); 308 newEvent.setStartDate(start); 309 newEvent.setEndDate(end); 310 newEvent.setSubject(name); 311 newEvent.setLocation(location); 312 calendarEvents.put(longStart, newEvent); 313 314 count++; 315 } 316 317 return calendarEvents.entrySet().stream().limit(maxEvents).map(e -> e.getValue()).collect(Collectors.toList()); 318 } 319 320 private String _getAttribute(Element item, String name) 321 { 322 String value = item.getAttribute(name); 323 if (StringUtils.isEmpty(value)) 324 { 325 value = ((Element) item.getParentNode()).getAttribute(name); 326 } 327 328 return value; 329 } 330 331 @Override 332 protected int internalGetEventsCount(UserIdentity userIdentity, int maxDays) throws MessagingConnectorException 333 { 334 Document response = _getSoapEvents(userIdentity, maxDays); 335 336 if (response == null) 337 { 338 return 0; 339 } 340 341 return _xPathProcessor.evaluateAsNumber(response, "count(/soap:Envelope/soap:Body/mail:SearchResponse/mail:appt/mail:inst)", __PREFIX_RESOLVER).intValue(); 342 } 343 344 private Document _getSoapEvents(UserIdentity userIdentity, int maxDays) throws MessagingConnectorException 345 { 346 ZonedDateTime now = ZonedDateTime.now(); 347 String soapRequestBody = "<SearchRequest xmlns=\"urn:zimbraMail\" types=\"appointment\" calExpandInstStart=\"" + now.toInstant().toEpochMilli() + "\" calExpandInstEnd =\"" + now.plusDays(maxDays).toInstant().toEpochMilli() + "\" >" 348 + " <query>in:calendar</query>" 349 + "</SearchRequest>"; 350 351 return _executeSoapRequest(userIdentity, soapRequestBody, "next events"); 352 } 353 354 @Override 355 protected List<EmailMessage> internalGetEmails(UserIdentity userIdentity, int maxEmails) throws MessagingConnectorException 356 { 357 String soapRequestBody = "<SearchRequest xmlns=\"urn:zimbraMail\" types=\"message\" limit=\"" + maxEmails + "\">" 358 + " <query>is:unread in:inbox</query>" 359 + "</SearchRequest>"; 360 361 Document document = _executeSoapRequest(userIdentity, soapRequestBody, "unread email"); 362 363 if (document != null) 364 { 365 NodeList emails = _xPathProcessor.selectNodeList(document, "/soap:Envelope/soap:Body/mail:SearchResponse/mail:m", __PREFIX_RESOLVER); 366 367 List<EmailMessage> result = new ArrayList<>(); 368 int count = 0; 369 while (count < emails.getLength()) 370 { 371 Node item = emails.item(count); 372 373 EmailMessage email = new EmailMessage(); 374 email.setSender(_xPathProcessor.evaluateAsString(item, "mail:e/@a", __PREFIX_RESOLVER)); 375 email.setSubject(_xPathProcessor.evaluateAsString(item, "mail:su", __PREFIX_RESOLVER)); 376 email.setSummary(_xPathProcessor.evaluateAsString(item, "mail:fr", __PREFIX_RESOLVER)); 377 378 result.add(email); 379 380 count++; 381 } 382 383 return result; 384 } 385 386 return Collections.emptyList(); 387 } 388 389 @Override 390 protected int internalGetEmailsCount(UserIdentity userIdentity) throws MessagingConnectorException 391 { 392 String soapRequestBody = "<GetFolderRequest xmlns=\"urn:zimbraMail\" depth=\"0\">" 393 + " <folder path=\"inbox\" />" 394 + "</GetFolderRequest>"; 395 396 Document document = _executeSoapRequest(userIdentity, soapRequestBody, "email count"); 397 if (document != null) 398 { 399 return _xPathProcessor.evaluateAsNumber(document, "/soap:Envelope/soap:Body/mail:GetFolderResponse/mail:folder/@u", __PREFIX_RESOLVER).intValue(); 400 } 401 402 return 0; 403 } 404 405 private Document _executeSoapRequest(UserIdentity userIdentity, String soapRequestBody, String shortDescription) throws MessagingConnectorException 406 { 407 // Connection to the zimbra mail server 408 String authToken = _getToken(userIdentity); 409 410 if (StringUtils.isEmpty(authToken)) 411 { 412 return null; 413 } 414 415 String soapRequest = "<soap:Envelope xmlns:soap=\"http://www.w3.org/2003/05/soap-envelope\">" 416 + " <soap:Header>" 417 + " <context xmlns=\"urn:zimbra\">" 418 + " <authToken>" + authToken + "</authToken>" 419 + " </context>" 420 + " </soap:Header>" 421 + " <soap:Body>" 422 + soapRequestBody 423 + " </soap:Body>" 424 + "</soap:Envelope>"; 425 426 HttpPost req = new HttpPost(_zimbraUrl + "/service/soap"); 427 req.setEntity(new StringEntity(soapRequest, StandardCharsets.UTF_8)); 428 429 try ( 430 CloseableHttpClient httpClient = _getHttpClient(); 431 CloseableHttpResponse response = httpClient.execute(req, HttpClientContext.create()) 432 ) 433 { 434 int status = response.getStatusLine().getStatusCode(); 435 if (status != 200) 436 { 437 getLogger().error("Zimbra failed on calling '{}' with status {} and reason: {}", shortDescription, status, response.getStatusLine().getReasonPhrase()); 438 if (getLogger().isDebugEnabled()) 439 { 440 StringBuilder sb = new StringBuilder("Headers:"); 441 for (Header header : response.getAllHeaders()) 442 { 443 sb.append("\n - ").append(header.getName()).append(": ").append(header.getValue()); 444 } 445 getLogger().debug(sb.toString()); 446 } 447 return null; 448 } 449 450 String content = EntityUtils.toString(response.getEntity()); 451 if (getLogger().isDebugEnabled()) 452 { 453 getLogger().debug("{}: {}", userIdentity, content); 454 } 455 Document document = _domParser.parseDocument(new InputSource(new StringReader(content))); 456 457 String failure = _xPathProcessor.evaluateAsString(document, "/soap:Envelope/soap:Body/soap:Fault/soap:Reason/soap:Text", __PREFIX_RESOLVER); 458 459 if (StringUtils.isEmpty(failure)) 460 { 461 return document; 462 } 463 else 464 { 465 getLogger().warn("Zimbra failed to return {} for user {}: {}", shortDescription, userIdentity, failure); 466 return null; 467 } 468 } 469 catch (IOException | SAXException e) 470 { 471 throw new MessagingConnectorException("Failed to get Zimbra " + shortDescription + " for user " + userIdentity, MessagingConnectorException.ExceptionType.UNKNOWN, e); 472 } 473 } 474 475 private static class ZimbraPrefixResolver implements PrefixResolver 476 { 477 private Map<String, String> _namespaces = Map.of("soap", "http://www.w3.org/2003/05/soap-envelope", 478 "mail", "urn:zimbraMail"); 479 480 public String prefixToNamespace(String prefix) 481 { 482 return _namespaces.get(prefix); 483 } 484 } 485 486 private String _getToken (UserIdentity user) 487 { 488 if (user == null) 489 { 490 return null; 491 } 492 493 Cache<UserIdentity, String> cache = _getCache(); 494 495 return cache.get(user, key -> _doPreauthRequest(key)); 496 } 497 498 private Cache<UserIdentity, String> _getCache() 499 { 500 return this._cacheManager.get(TOKEN_CACHE); 501 } 502}