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