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