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.data.holder.ModifiableModelLessDataHolder;
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    // This callable is only use before validation to check if the newsletter was already sent
131    @Callable(rights = "Plugins_Newsletter_Right_ValidateNewsletters", paramIndex = 0, rightContext = ContentRightAssignmentContext.ID)
132    public boolean isSent (String newsletterId)
133    {
134        Content content = _resolver.resolveById(newsletterId);
135        return content.getInternalDataHolder().getValue("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    public Map<String, Object> getNewsletterProperties(Content newsletter)
144    {
145        Map<String, Object> infos = new HashMap<>();
146        
147        infos.put("id", newsletter.getId());
148        infos.put("title", newsletter.getTitle());
149        infos.put("name", newsletter.getName());
150        infos.put("automatic", newsletter.getInternalDataHolder().getValue("automatic", false));
151        
152        return infos;
153    }
154    
155    /**
156     * Send the newsletter to a single recipient, while ignoring the subscribers or the workflow state
157     * @param newsletterId The newsletter id
158     * @param recipientEmail The recipient 
159     * @return True if the newsletter was sent
160     * @throws IllegalAccessException If a user tried to send a newsletter with insufficient rights
161     */
162    @Callable(rights = __SEND_TESTING_RIGHT) // This right is assigned on general context, not per newsletter
163    public boolean sendTestNewsletter(String newsletterId, String recipientEmail) throws IllegalAccessException
164    {
165        ModifiableWebContent content = _resolver.resolveById(newsletterId);
166
167        if (!(content instanceof DefaultWebContent))
168        {
169            throw new UnknownAmetysObjectException("Unable to send newsletter, invalid newsletter id provider '" + newsletterId + "'");
170        }
171        
172        getLogger().info("The user {} sent the newsletter {} to {}", _currentUserProvider.getUser(), newsletterId, recipientEmail); 
173        
174        String uid;
175        ModifiableModelLessDataHolder internalDataHolder = content.getInternalDataHolder();
176        if (!internalDataHolder.hasValue(__TEST_UNIQUE_ID_METADATA))
177        {
178            uid = UUID.randomUUID().toString();
179            internalDataHolder.setValue(__TEST_UNIQUE_ID_METADATA, uid);
180            content.saveChanges();
181        }
182        else
183        {
184            uid = internalDataHolder.getValue(__TEST_UNIQUE_ID_METADATA, null);
185        }
186        
187        String siteName = (String) ContextHelper.getRequest(_context).getAttribute("siteName");
188        Site site = _siteManager.getSite(siteName);
189        boolean includeImages = site.getValue("newsletter-mail-include-images", false, false);
190        
191        String dataHolderUid = null;
192        if (!includeImages && uid != null)
193        {
194            // create or update temporary content to serve images on the live workspaces
195            dataHolderUid = _useDataHolderContent(site, content, uid);
196        }
197        
198        try
199        {
200            sendNewsletter((DefaultWebContent) content, ImmutableMap.of(recipientEmail, "#token#"), dataHolderUid);
201        }
202        catch (IOException e)
203        {
204            getLogger().error("Unable to send the newsletter", e);
205            return false;
206        }
207        
208        return true;
209    }
210    
211    private String _useDataHolderContent(Site site, WebContent realContent, String uid)
212    {
213        try
214        {
215            RichText richText = realContent.getValue("content");
216            
217            if (!richText.getAttachmentNames().isEmpty())
218            {
219                return _createDataHolderContent(site, realContent, uid, richText);
220            }
221        }
222        catch (RepositoryException | IOException e)
223        {
224            getLogger().error("A repository error occurred when creating the data holder temporary content, when sending a test newsletter", e);
225        }
226        
227        return null;
228    }
229
230    private String _createDataHolderContent(Site site, WebContent realContent, String uid, RichText richText) throws RepositoryException, IOException
231    {
232        Session liveSession = null;
233        try
234        {
235            liveSession = _repository.login(WebConstants.LIVE_WORKSPACE);
236            ModifiableTraversableAmetysObject siteContents = _resolver.resolveByPath(site.getPath() + "/" + RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":contents");
237            ModifiableTraversableAmetysObject liveSiteContents = _resolver.resolveById(siteContents.getId(), liveSession);
238            
239            String contentName = realContent.getName() + "-test-" + uid;
240            ModifiableWebContent dataHolderContent = null;
241            
242            if (!liveSiteContents.hasChild(contentName))
243            {
244                dataHolderContent = liveSiteContents.createChild(contentName, RepositoryConstants.NAMESPACE_PREFIX + ":defaultWebContent");
245                dataHolderContent.setTypes(new String[] {__NEWSLETTER_CONTENT_TYPE});
246                dataHolderContent.setTitle(realContent.getTitle());
247                dataHolderContent.setSiteName(realContent.getSiteName());
248                dataHolderContent.setLanguage(realContent.getLanguage());
249                dataHolderContent.setLastModified(realContent.getLastModified());
250            }
251            else
252            {
253                dataHolderContent = liveSiteContents.getChild(contentName);
254            }
255            
256            richText.setInputStream(new ByteArrayInputStream("unused".getBytes(StandardCharsets.UTF_8)));
257            dataHolderContent.setValue("content", richText);
258            
259            dataHolderContent.saveChanges();
260            
261            return dataHolderContent.getId();
262        }
263        finally
264        {
265            if (liveSession != null)
266            {
267                liveSession.logout();
268            }
269        }
270    }
271    
272    /**
273     * Send the newsletter to the recipients
274     * @param content The newsletter
275     * @param recipients The recipients of the newsletter
276     * @throws IOException If an error occurred 
277     */
278    public void sendNewsletter(DefaultWebContent content, Map<String, String> recipients) throws IOException
279    {
280        sendNewsletter(content, recipients, null);
281    }
282    
283    /**
284     * Send the newsletter to the recipients
285     * @param content The newsletter
286     * @param recipients The recipients of the newsletter
287     * @param dataHolderId The content to use as a data holder proxy for images. Can be null
288     * @throws IOException If an error occurred 
289     */
290    public void sendNewsletter(DefaultWebContent content, Map<String, String> recipients, String dataHolderId) throws IOException
291    {
292        String subject = _getSubject (content);
293        String htmlBody = _getBodyAsHtml(content, dataHolderId);
294        String textBody = _getBodyAsText(content, dataHolderId);
295        
296        Site site = content.getSite();
297
298        String sender = site.getValue("newsletter-mail-sender");
299        
300        // Send the mail
301        SendMailEngine sendEngine = new SendMailEngine();
302        sendEngine.parameterize(subject, htmlBody, textBody, recipients, sender);
303        
304        new Thread(sendEngine).start();
305    }
306
307    /**
308     * Get the newsletter mail subject
309     * @param content The content
310     * @return The subject
311     */
312    protected String _getSubject (DefaultWebContent content)
313    {
314        List<String> i18nparam = new ArrayList<>();
315        i18nparam.add(content.getSite().getTitle()); // {0} site
316        i18nparam.add(content.getTitle()); // {1} title
317        i18nparam.add(String.valueOf(content.getValue("newsletter-number", false, 0L))); // {2} number
318
319        String categoryId = content.getInternalDataHolder().getValue("category");
320        Category category = getCategory(categoryId);
321        i18nparam.add(category != null ? category.getTitle().getLabel() : StringUtils.EMPTY); // {3} category
322        
323        I18nizableText i18nTextSubject = new I18nizableText("plugin.newsletter", "PLUGINS_NEWSLETTER_SEND_MAIL_SUBJECT", i18nparam);
324        return _i18nUtils.translate(i18nTextSubject);
325    }
326    
327    /**
328     * Get the newsletter HTML body
329     * @param content The content
330     * @param dataHolderId The data holder content to use as proxy images
331     * @return The body 
332     * @throws IOException if an I/O error occurred
333     */
334    protected String _getBodyAsHtml (DefaultWebContent content, String dataHolderId) throws IOException
335    {
336        SitemapSource src = null;
337        Request request = ContextHelper.getRequest(_context);
338        
339        Site site = content.getSite();
340        boolean includeImages = site.getValue("newsletter-mail-include-images", false, false);
341        
342        if (includeImages)
343        {
344            request.setAttribute("forceBase64Encoding", true);
345        }
346        
347        RenderingContext renderingContext = _renderingContextHandler.getRenderingContext();
348        try
349        {
350            _renderingContextHandler.setRenderingContext(RenderingContext.FRONT);
351        
352            String uri = "cocoon://_content.mail?contentId=" + content.getId() + "&site=" + content.getSiteName() + "&lang=" + content.getLanguage() + "&_contextPath=" + content.getSite().getUrl();
353            if (StringUtils.isNotEmpty(dataHolderId))
354            {
355                uri += "&useDataHolderContent=" + dataHolderId;
356            }
357            src = (SitemapSource) _sourceResolver.resolveURI(uri);
358            Reader reader = new InputStreamReader(src.getInputStream(), "UTF-8");
359            return IOUtils.toString(reader);
360        }
361        finally
362        {
363            _sourceResolver.release(src);
364            _renderingContextHandler.setRenderingContext(renderingContext);
365            request.removeAttribute("forceBase64Encoding");
366        }
367    }
368    
369    /**
370     * Get the newsletter text body
371     * @param content The content
372     * @param dataHolderId The data holder content to use as proxy images
373     * @return The body 
374     * @throws IOException if an I/O error occurred
375     */
376    protected String _getBodyAsText (DefaultWebContent content, String dataHolderId) throws IOException
377    {
378        SitemapSource src = null;
379        
380        RenderingContext renderingContext = _renderingContextHandler.getRenderingContext();
381        try
382        {
383            _renderingContextHandler.setRenderingContext(RenderingContext.FRONT);
384            
385            String uri = "cocoon://_content.text?contentId=" + content.getId() + "&site=" + content.getSiteName() + "&lang=" + content.getLanguage() + "&_contextPath=" + content.getSite().getUrl();
386            if (StringUtils.isNotEmpty(dataHolderId))
387            {
388                uri += "&useDataHolderContent=" + dataHolderId;
389            }
390            src = (SitemapSource) _sourceResolver.resolveURI(uri);
391            Reader reader = new InputStreamReader(src.getInputStream(), "UTF-8");
392            return IOUtils.toString(reader);
393        }
394        finally
395        {
396            _sourceResolver.release(src);
397            _renderingContextHandler.setRenderingContext(renderingContext);
398        }
399    }
400    
401    /**
402     * Get a category
403     * @param categoryID The category id
404     * @return The category
405     */
406    public Category getCategory (String categoryID)
407    {
408        Set<String> ids = _categoryProviderEP.getExtensionsIds();
409        for (String id : ids)
410        {
411            CategoryProvider provider = _categoryProviderEP.getExtension(id);
412            if (!categoryID.startsWith("provider_") && provider.hasCategory(categoryID))
413            {
414                return provider.getCategory(categoryID);
415            }
416        }
417        
418        return null;
419    }
420    
421
422    /**
423     * Remove the test newsletter if it exists in live workspace
424     * @param content The content
425     * @param site The site of the content
426     * @throws RepositoryException If an error occurred
427     */
428    public void removeTestNewsletter(WebContent content, Site site) throws RepositoryException
429    {
430        if (content.getInternalDataHolder().hasValue(__TEST_UNIQUE_ID_METADATA))
431        {
432            Session liveSession = null;
433            try
434            {
435                String testUniqueId = content.getInternalDataHolder().getValue(__TEST_UNIQUE_ID_METADATA);
436                liveSession = _repository.login(WebConstants.LIVE_WORKSPACE);
437                ModifiableTraversableAmetysObject siteContents = _resolver.resolveByPath(site.getPath() + "/" + RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":contents");
438                ModifiableTraversableAmetysObject liveSiteContents = _resolver.resolveById(siteContents.getId(), liveSession);
439                
440                String contentName = content.getName() + "-test-" + testUniqueId;
441                
442                if (liveSiteContents.hasChild(contentName))
443                {
444                    AmetysObject child = liveSiteContents.getChild(contentName);
445                    if (child instanceof RemovableAmetysObject)
446                    {
447                        ((RemovableAmetysObject) child).remove();
448                        liveSiteContents.saveChanges();
449                    }
450                }
451                
452                content.getInternalDataHolder().removeValue(__TEST_UNIQUE_ID_METADATA);
453            }
454            finally
455            {
456                if (liveSession != null)
457                {
458                    liveSession.logout();
459                }
460            }
461        }
462    }
463}