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}