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}