001/* 002 * Copyright 2011 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.newsletter; 017 018import java.io.IOException; 019import java.io.InputStreamReader; 020import java.io.Reader; 021import java.util.HashMap; 022import java.util.Map; 023import java.util.Set; 024import java.util.UUID; 025 026import javax.jcr.Node; 027import javax.jcr.NodeIterator; 028import javax.jcr.PathNotFoundException; 029import javax.jcr.Property; 030import javax.jcr.PropertyIterator; 031import javax.jcr.Repository; 032import javax.jcr.RepositoryException; 033import javax.jcr.Session; 034 035import org.apache.avalon.framework.component.Component; 036import org.apache.avalon.framework.context.Context; 037import org.apache.avalon.framework.context.ContextException; 038import org.apache.avalon.framework.context.Contextualizable; 039import org.apache.avalon.framework.service.ServiceException; 040import org.apache.avalon.framework.service.ServiceManager; 041import org.apache.avalon.framework.service.Serviceable; 042import org.apache.cocoon.components.ContextHelper; 043import org.apache.cocoon.components.source.impl.SitemapSource; 044import org.apache.cocoon.environment.Request; 045import org.apache.commons.collections.Predicate; 046import org.apache.commons.collections.PredicateUtils; 047import org.apache.commons.io.IOUtils; 048import org.apache.commons.lang3.StringUtils; 049import org.apache.excalibur.source.SourceResolver; 050 051import org.ametys.cms.repository.Content; 052import org.ametys.cms.rights.ContentRightAssignmentContext; 053import org.ametys.cms.support.AmetysPredicateUtils; 054import org.ametys.core.ui.Callable; 055import org.ametys.core.user.CurrentUserProvider; 056import org.ametys.plugins.newsletter.category.Category; 057import org.ametys.plugins.newsletter.category.CategoryProvider; 058import org.ametys.plugins.newsletter.category.CategoryProviderExtensionPoint; 059import org.ametys.plugins.newsletter.workflow.SendMailEngine; 060import org.ametys.plugins.repository.AmetysObject; 061import org.ametys.plugins.repository.AmetysObjectResolver; 062import org.ametys.plugins.repository.ModifiableTraversableAmetysObject; 063import org.ametys.plugins.repository.RemovableAmetysObject; 064import org.ametys.plugins.repository.RepositoryConstants; 065import org.ametys.plugins.repository.UnknownAmetysObjectException; 066import org.ametys.plugins.repository.jcr.JCRAmetysObject; 067import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata; 068import org.ametys.plugins.workflow.repository.WorkflowAwareAmetysObject; 069import org.ametys.runtime.plugin.component.AbstractLogEnabled; 070import org.ametys.web.WebConstants; 071import org.ametys.web.renderingcontext.RenderingContext; 072import org.ametys.web.renderingcontext.RenderingContextHandler; 073import org.ametys.web.repository.content.ModifiableWebContent; 074import org.ametys.web.repository.content.WebContent; 075import org.ametys.web.repository.content.jcr.DefaultWebContent; 076import org.ametys.web.repository.site.Site; 077import org.ametys.web.repository.site.SiteManager; 078 079import com.google.common.collect.ImmutableMap; 080 081 082/** 083 * DAO for manipulating newsletter 084 * 085 */ 086public class NewsletterDAO extends AbstractLogEnabled implements Serviceable, Component, Contextualizable 087{ 088 /** The Avalon role */ 089 public static final String ROLE = NewsletterDAO.class.getName(); 090 091 /** Right to send a test newsletter */ 092 public static final String __SEND_TESTING_RIGHT = "Plugins_Newsletter_Right_TestSending"; 093 094 /** Newsletter content type */ 095 public static final String __NEWSLETTER_CONTENT_TYPE = "org.ametys.plugins.newsletter.Content.newsletter"; 096 097 /** Metadata test-unique-id */ 098 public static final String __TEST_UNIQUE_ID_METADATA = "test-unique-id"; 099 100 private AmetysObjectResolver _resolver; 101 private CurrentUserProvider _currentUserProvider; 102 private RenderingContextHandler _renderingContextHandler; 103 private SourceResolver _sourceResolver; 104 private CategoryProviderExtensionPoint _categoryProviderEP; 105 private Context _context; 106 private SiteManager _siteManager; 107 private Repository _repository; 108 109 @Override 110 public void service(ServiceManager smanager) throws ServiceException 111 { 112 _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE); 113 _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE); 114 _renderingContextHandler = (RenderingContextHandler) smanager.lookup(RenderingContextHandler.ROLE); 115 _sourceResolver = (SourceResolver) smanager.lookup(SourceResolver.ROLE); 116 _categoryProviderEP = (CategoryProviderExtensionPoint) smanager.lookup(CategoryProviderExtensionPoint.ROLE); 117 _siteManager = (SiteManager) smanager.lookup(SiteManager.ROLE); 118 _repository = (Repository) smanager.lookup(Repository.class.getName()); 119 } 120 121 public void contextualize(Context context) throws ContextException 122 { 123 _context = context; 124 } 125 126 /** 127 * Determines if the newsletter was already sent 128 * @param newsletterId the id of newsletter 129 * @return true if the newsletter was already sent 130 */ 131 @Callable 132 public boolean isSent (String newsletterId) 133 { 134 Content content = _resolver.resolveById(newsletterId); 135 // TODO NEWATTRIBUTEAPI_CONTENT: There is no attribute sent on newsletter content. See how to deal with this metadata 136 return content.getMetadataHolder().getBoolean("sent", false); 137 } 138 139 /** 140 * Gets newsletter's properties to JSON format 141 * @param newsletter The newsletter 142 * @return The newsletter's properties 143 */ 144 @Callable 145 public Map<String, Object> getNewsletterProperties(Content newsletter) 146 { 147 Map<String, Object> infos = new HashMap<>(); 148 149 infos.put("id", newsletter.getId()); 150 infos.put("title", newsletter.getTitle()); 151 infos.put("name", newsletter.getName()); 152 // TODO NEWATTRIBUTEAPI_CONTENT: There is no attribute automatic on newsletter content. See how to deal with this metadata 153 infos.put("automatic", newsletter.getMetadataHolder().getBoolean("automatic", false)); 154 155 return infos; 156 } 157 158 /** 159 * Send the newsletter to a single recipient, while ignoring the subscribers or the workflow state 160 * @param newsletterId The newsletter id 161 * @param recipientEmail The recipient 162 * @return True if the newsletter was sent 163 * @throws IllegalAccessException If a user tried to send a newsletter with insufficient rights 164 */ 165 @Callable(right = __SEND_TESTING_RIGHT, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0) 166 public boolean sendTestNewsletter(String newsletterId, String recipientEmail) throws IllegalAccessException 167 { 168 ModifiableWebContent content = _resolver.resolveById(newsletterId); 169 170 if (!(content instanceof DefaultWebContent)) 171 { 172 throw new UnknownAmetysObjectException("Unable to send newsletter, invalid newsletter id provider '" + newsletterId + "'"); 173 } 174 175 getLogger().info("The user {} sent the newsletter {} to {}", _currentUserProvider.getUser(), newsletterId, recipientEmail); 176 177 String uid; 178 if (!content.getMetadataHolder().hasMetadata(__TEST_UNIQUE_ID_METADATA)) 179 { 180 uid = UUID.randomUUID().toString(); 181 content.getMetadataHolder().setMetadata(__TEST_UNIQUE_ID_METADATA, uid); 182 content.saveChanges(); 183 } 184 else 185 { 186 uid = content.getMetadataHolder().getString(__TEST_UNIQUE_ID_METADATA, null); 187 } 188 189 String siteName = (String) ContextHelper.getRequest(_context).getAttribute("siteName"); 190 Site site = _siteManager.getSite(siteName); 191 boolean includeImages = site.getValue("newsletter-mail-include-images", false, false); 192 193 String dataHolderUid = null; 194 if (!includeImages && uid != null) 195 { 196 // create or update temporary content to serve images on the live workspaces 197 dataHolderUid = _useDataHolderContent(site, content, uid); 198 } 199 200 try 201 { 202 sendNewsletter((DefaultWebContent) content, ImmutableMap.of(recipientEmail, "#token#"), dataHolderUid); 203 } 204 catch (IOException e) 205 { 206 getLogger().error("Unable to send the newsletter", e); 207 return false; 208 } 209 210 return true; 211 } 212 213 private String _useDataHolderContent(Site site, WebContent realContent, String uid) 214 { 215 try 216 { 217 if (!(realContent instanceof WorkflowAwareAmetysObject)) 218 { 219 return null; 220 } 221 222 Node realContentNode = ((WorkflowAwareAmetysObject) realContent).getNode(); 223 NodeIterator realContentDataNodes = null; 224 try 225 { 226 Node realContentDataNode = realContentNode.getNode(RepositoryConstants.NAMESPACE_PREFIX + ":content/data"); 227 realContentDataNodes = realContentDataNode.getNodes(); 228 } 229 catch (PathNotFoundException e) 230 { 231 // no image in the richtext, ignore 232 } 233 234 235 if (realContentDataNodes != null && realContentDataNodes.getSize() > 0) 236 { 237 return _createDataHolderContentWithNodes(site, realContent, uid, realContentDataNodes); 238 } 239 } 240 catch (RepositoryException e) 241 { 242 getLogger().error("A repository error occurred when creating the data holder temporary content, when sending a test newsletter", e); 243 } 244 245 return null; 246 } 247 248 private String _createDataHolderContentWithNodes(Site site, WebContent realContent, String uid, NodeIterator realContentDataNodes) throws RepositoryException 249 { 250 Session liveSession = null; 251 try 252 { 253 liveSession = _repository.login(WebConstants.LIVE_WORKSPACE); 254 ModifiableTraversableAmetysObject siteContents = _resolver.resolveByPath(site.getPath() + "/" + RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":contents"); 255 ModifiableTraversableAmetysObject liveSiteContents = _resolver.resolveById(siteContents.getId(), liveSession); 256 257 String contentName = realContent.getName() + "-test-" + uid; 258 ModifiableWebContent dataHolderContent = null; 259 260 if (!liveSiteContents.hasChild(contentName)) 261 { 262 dataHolderContent = liveSiteContents.createChild(contentName, RepositoryConstants.NAMESPACE_PREFIX + ":defaultWebContent"); 263 dataHolderContent.setTypes(new String[] {__NEWSLETTER_CONTENT_TYPE}); 264 dataHolderContent.setTitle(realContent.getTitle()); 265 dataHolderContent.setSiteName(realContent.getSiteName()); 266 dataHolderContent.setLanguage(realContent.getLanguage()); 267 dataHolderContent.setLastModified(realContent.getLastModified()); 268 } 269 else 270 { 271 dataHolderContent = liveSiteContents.getChild(contentName); 272 } 273 274 275 if (dataHolderContent instanceof JCRAmetysObject) 276 { 277 Node contentNode = ((JCRAmetysObject) dataHolderContent).getNode(); 278 Node dataHolderContentNode = _getOrCreateNode(contentNode, RepositoryConstants.NAMESPACE_PREFIX + ":content", "nt:folder"); 279 Node dataHolderNode = _getOrCreateNode(dataHolderContentNode, "data", "nt:folder"); 280 281 NodeIterator children = dataHolderNode.getNodes(); 282 while (children.hasNext()) 283 { 284 children.nextNode().remove(); 285 } 286 287 while (realContentDataNodes.hasNext()) 288 { 289 _copyNode(dataHolderNode, realContentDataNodes.nextNode()); 290 } 291 292 dataHolderContent.saveChanges(); 293 } 294 return dataHolderContent != null ? dataHolderContent.getId() : null; 295 } 296 finally 297 { 298 if (liveSession != null) 299 { 300 liveSession.logout(); 301 } 302 } 303 } 304 305 private Node _getOrCreateNode(Node node, String name, String type) throws RepositoryException 306 { 307 if (!node.hasNode(name)) 308 { 309 return node.addNode(name, type); 310 } 311 else 312 { 313 return node.getNode(name); 314 } 315 } 316 317 private void _copyNode(Node parent, Node nodeToCopy) throws RepositoryException 318 { 319 Node newNode = parent.addNode(nodeToCopy.getName(), nodeToCopy.getPrimaryNodeType().getName()); 320 321 Predicate ignoreProtectedProperties = AmetysPredicateUtils.ignoreProtectedProperties(PredicateUtils.truePredicate()); 322 323 PropertyIterator properties = nodeToCopy.getProperties(); 324 while (properties.hasNext()) 325 { 326 Property property = properties.nextProperty(); 327 if (ignoreProtectedProperties.evaluate(property)) 328 { 329 if (property.getDefinition().isMultiple()) 330 { 331 newNode.setProperty(property.getName(), property.getValues()); 332 } 333 else 334 { 335 newNode.setProperty(property.getName(), property.getValue()); 336 } 337 } 338 } 339 340 NodeIterator nodeChildrenToCopy = nodeToCopy.getNodes(); 341 while (nodeChildrenToCopy.hasNext()) 342 { 343 _copyNode(newNode, nodeChildrenToCopy.nextNode()); 344 } 345 } 346 347 /** 348 * Send the newsletter to the recipients 349 * @param content The newsletter 350 * @param recipients The recipients of the newsletter 351 * @throws IOException If an error occurred 352 */ 353 public void sendNewsletter(DefaultWebContent content, Map<String, String> recipients) throws IOException 354 { 355 sendNewsletter(content, recipients, null); 356 } 357 358 /** 359 * Send the newsletter to the recipients 360 * @param content The newsletter 361 * @param recipients The recipients of the newsletter 362 * @param dataHolderId The content to use as a data holder proxy for images. Can be null 363 * @throws IOException If an error occurred 364 */ 365 public void sendNewsletter(DefaultWebContent content, Map<String, String> recipients, String dataHolderId) throws IOException 366 { 367 String subject = _getSubject (content); 368 String htmlBody = _getBodyAsHtml(content, dataHolderId); 369 String textBody = _getBodyAsText(content, dataHolderId); 370 371 Site site = content.getSite(); 372 373 String sender = site.getValue("newsletter-mail-sender"); 374 375 // Send the mail 376 SendMailEngine sendEngine = new SendMailEngine(); 377 sendEngine.parameterize(subject, htmlBody, textBody, recipients, sender); 378 379 new Thread(sendEngine).start(); 380 } 381 382 /** 383 * Get the newsletter mail subject 384 * @param content The content 385 * @return The subject 386 */ 387 protected String _getSubject (DefaultWebContent content) 388 { 389 String subject = "[" + content.getSite().getTitle() + "] "; 390 // TODO NEWATTRIBUTEAPI_CONTENT: There is no attribute category on newsletter content. See how to deal with this metadata 391 String categoryId = content.getMetadataHolder().getString("category"); 392 Category category = getCategory(categoryId); 393 if (category != null) 394 { 395 subject += category.getTitle().getLabel() + " - "; 396 } 397 subject += content.getTitle(); 398 399 return subject; 400 } 401 402 /** 403 * Get the newsletter HTML body 404 * @param content The content 405 * @param dataHolderId The data holder content to use as proxy images 406 * @return The body 407 * @throws IOException if an I/O error occurred 408 */ 409 protected String _getBodyAsHtml (DefaultWebContent content, String dataHolderId) throws IOException 410 { 411 SitemapSource src = null; 412 Request request = ContextHelper.getRequest(_context); 413 414 Site site = content.getSite(); 415 boolean includeImages = site.getValue("newsletter-mail-include-images", false, false); 416 417 if (includeImages) 418 { 419 request.setAttribute("forceBase64Encoding", true); 420 } 421 422 RenderingContext renderingContext = _renderingContextHandler.getRenderingContext(); 423 try 424 { 425 _renderingContextHandler.setRenderingContext(RenderingContext.FRONT); 426 427 String uri = "cocoon://_content.mail?contentId=" + content.getId() + "&site=" + content.getSiteName() + "&lang=" + content.getLanguage() + "&_contextPath=" + content.getSite().getUrl(); 428 if (StringUtils.isNotEmpty(dataHolderId)) 429 { 430 uri += "&useDataHolderContent=" + dataHolderId; 431 } 432 src = (SitemapSource) _sourceResolver.resolveURI(uri); 433 Reader reader = new InputStreamReader(src.getInputStream(), "UTF-8"); 434 return IOUtils.toString(reader); 435 } 436 finally 437 { 438 _sourceResolver.release(src); 439 _renderingContextHandler.setRenderingContext(renderingContext); 440 request.removeAttribute("forceBase64Encoding"); 441 } 442 } 443 444 /** 445 * Get the newsletter text body 446 * @param content The content 447 * @param dataHolderId The data holder content to use as proxy images 448 * @return The body 449 * @throws IOException if an I/O error occurred 450 */ 451 protected String _getBodyAsText (DefaultWebContent content, String dataHolderId) throws IOException 452 { 453 SitemapSource src = null; 454 455 RenderingContext renderingContext = _renderingContextHandler.getRenderingContext(); 456 try 457 { 458 _renderingContextHandler.setRenderingContext(RenderingContext.FRONT); 459 460 String uri = "cocoon://_content.text?contentId=" + content.getId() + "&site=" + content.getSiteName() + "&lang=" + content.getLanguage() + "&_contextPath=" + content.getSite().getUrl(); 461 if (StringUtils.isNotEmpty(dataHolderId)) 462 { 463 uri += "&useDataHolderContent=" + dataHolderId; 464 } 465 src = (SitemapSource) _sourceResolver.resolveURI(uri); 466 Reader reader = new InputStreamReader(src.getInputStream(), "UTF-8"); 467 return IOUtils.toString(reader); 468 } 469 finally 470 { 471 _sourceResolver.release(src); 472 _renderingContextHandler.setRenderingContext(renderingContext); 473 } 474 } 475 476 /** 477 * Get a category 478 * @param categoryID The category id 479 * @return The category 480 */ 481 public Category getCategory (String categoryID) 482 { 483 Set<String> ids = _categoryProviderEP.getExtensionsIds(); 484 for (String id : ids) 485 { 486 CategoryProvider provider = _categoryProviderEP.getExtension(id); 487 if (!categoryID.startsWith("provider_") && provider.hasCategory(categoryID)) 488 { 489 return provider.getCategory(categoryID); 490 } 491 } 492 493 return null; 494 } 495 496 497 /** 498 * Remove the test newsletter if it exists in live workspace 499 * @param content The content 500 * @param site The site of the content 501 * @throws RepositoryException If an error occurred 502 */ 503 public void removeTestNewsletter(WebContent content, Site site) throws RepositoryException 504 { 505 if (content.getMetadataHolder().hasMetadata(__TEST_UNIQUE_ID_METADATA)) 506 { 507 Session liveSession = null; 508 try 509 { 510 String testUniqueId = content.getMetadataHolder().getString(__TEST_UNIQUE_ID_METADATA); 511 liveSession = _repository.login(WebConstants.LIVE_WORKSPACE); 512 ModifiableTraversableAmetysObject siteContents = _resolver.resolveByPath(site.getPath() + "/" + RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":contents"); 513 ModifiableTraversableAmetysObject liveSiteContents = _resolver.resolveById(siteContents.getId(), liveSession); 514 515 String contentName = content.getName() + "-test-" + testUniqueId; 516 517 if (liveSiteContents.hasChild(contentName)) 518 { 519 AmetysObject child = liveSiteContents.getChild(contentName); 520 if (child instanceof RemovableAmetysObject) 521 { 522 ((RemovableAmetysObject) child).remove(); 523 liveSiteContents.saveChanges(); 524 } 525 } 526 527 ((ModifiableCompositeMetadata) content.getMetadataHolder()).removeMetadata(__TEST_UNIQUE_ID_METADATA); 528 } 529 finally 530 { 531 if (liveSession != null) 532 { 533 liveSession.logout(); 534 } 535 } 536 } 537 } 538}