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}