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