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.IOException;
019import java.io.InputStreamReader;
020import java.io.Reader;
021import java.util.HashMap;
022import java.util.Map;
023import java.util.Set;
024import java.util.UUID;
025
026import javax.jcr.Node;
027import javax.jcr.NodeIterator;
028import javax.jcr.PathNotFoundException;
029import javax.jcr.Property;
030import javax.jcr.PropertyIterator;
031import javax.jcr.Repository;
032import javax.jcr.RepositoryException;
033import javax.jcr.Session;
034
035import org.apache.avalon.framework.component.Component;
036import org.apache.avalon.framework.context.Context;
037import org.apache.avalon.framework.context.ContextException;
038import org.apache.avalon.framework.context.Contextualizable;
039import org.apache.avalon.framework.service.ServiceException;
040import org.apache.avalon.framework.service.ServiceManager;
041import org.apache.avalon.framework.service.Serviceable;
042import org.apache.cocoon.components.ContextHelper;
043import org.apache.cocoon.components.source.impl.SitemapSource;
044import org.apache.cocoon.environment.Request;
045import org.apache.commons.collections.Predicate;
046import org.apache.commons.collections.PredicateUtils;
047import org.apache.commons.io.IOUtils;
048import org.apache.commons.lang3.StringUtils;
049import org.apache.excalibur.source.SourceResolver;
050
051import org.ametys.cms.repository.Content;
052import org.ametys.cms.rights.ContentRightAssignmentContext;
053import org.ametys.cms.support.AmetysPredicateUtils;
054import org.ametys.core.ui.Callable;
055import org.ametys.core.user.CurrentUserProvider;
056import org.ametys.plugins.newsletter.category.Category;
057import org.ametys.plugins.newsletter.category.CategoryProvider;
058import org.ametys.plugins.newsletter.category.CategoryProviderExtensionPoint;
059import org.ametys.plugins.newsletter.workflow.SendMailEngine;
060import org.ametys.plugins.repository.AmetysObject;
061import org.ametys.plugins.repository.AmetysObjectResolver;
062import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
063import org.ametys.plugins.repository.RemovableAmetysObject;
064import org.ametys.plugins.repository.RepositoryConstants;
065import org.ametys.plugins.repository.UnknownAmetysObjectException;
066import org.ametys.plugins.repository.jcr.JCRAmetysObject;
067import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
068import org.ametys.plugins.workflow.repository.WorkflowAwareAmetysObject;
069import org.ametys.runtime.plugin.component.AbstractLogEnabled;
070import org.ametys.web.WebConstants;
071import org.ametys.web.renderingcontext.RenderingContext;
072import org.ametys.web.renderingcontext.RenderingContextHandler;
073import org.ametys.web.repository.content.ModifiableWebContent;
074import org.ametys.web.repository.content.WebContent;
075import org.ametys.web.repository.content.jcr.DefaultWebContent;
076import org.ametys.web.repository.site.Site;
077import org.ametys.web.repository.site.SiteManager;
078
079import com.google.common.collect.ImmutableMap;
080
081
082/**
083 * DAO for manipulating newsletter
084 *
085 */
086public class NewsletterDAO extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
087{
088    /** The Avalon role */
089    public static final String ROLE = NewsletterDAO.class.getName();
090    
091    /** Right to send a test newsletter */
092    public static final String __SEND_TESTING_RIGHT = "Plugins_Newsletter_Right_TestSending";
093    
094    /** Newsletter content type */
095    public static final String __NEWSLETTER_CONTENT_TYPE = "org.ametys.plugins.newsletter.Content.newsletter";
096    
097    /** Metadata test-unique-id */
098    public static final String __TEST_UNIQUE_ID_METADATA = "test-unique-id";
099
100    private AmetysObjectResolver _resolver;
101    private CurrentUserProvider _currentUserProvider;
102    private RenderingContextHandler _renderingContextHandler;
103    private SourceResolver _sourceResolver;
104    private CategoryProviderExtensionPoint _categoryProviderEP;
105    private Context _context;
106    private SiteManager _siteManager;
107    private Repository _repository;
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    }
120    
121    public void contextualize(Context context) throws ContextException
122    {
123        _context = context;
124    }
125    
126    /**
127     * Determines if the newsletter was already sent
128     * @param newsletterId the id of newsletter
129     * @return true if the newsletter was already sent
130     */
131    @Callable
132    public boolean isSent (String newsletterId)
133    {
134        Content content = _resolver.resolveById(newsletterId);
135        // TODO NEWATTRIBUTEAPI_CONTENT: There is no attribute sent on newsletter content. See how to deal with this metadata
136        return content.getMetadataHolder().getBoolean("sent", false);
137    }
138    
139    /**
140     * Gets newsletter's properties to JSON format
141     * @param newsletter The newsletter
142     * @return The newsletter's properties
143     */
144    @Callable
145    public Map<String, Object> getNewsletterProperties(Content newsletter)
146    {
147        Map<String, Object> infos = new HashMap<>();
148        
149        infos.put("id", newsletter.getId());
150        infos.put("title", newsletter.getTitle());
151        infos.put("name", newsletter.getName());
152        // TODO NEWATTRIBUTEAPI_CONTENT: There is no attribute automatic on newsletter content. See how to deal with this metadata
153        infos.put("automatic", newsletter.getMetadataHolder().getBoolean("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(right = __SEND_TESTING_RIGHT, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
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        if (!content.getMetadataHolder().hasMetadata(__TEST_UNIQUE_ID_METADATA))
179        {
180            uid = UUID.randomUUID().toString();
181            content.getMetadataHolder().setMetadata(__TEST_UNIQUE_ID_METADATA, uid);
182            content.saveChanges();
183        }
184        else
185        {
186            uid = content.getMetadataHolder().getString(__TEST_UNIQUE_ID_METADATA, null);
187        }
188        
189        String siteName = (String) ContextHelper.getRequest(_context).getAttribute("siteName");
190        Site site = _siteManager.getSite(siteName);
191        boolean includeImages = site.getValue("newsletter-mail-include-images", false, false);
192        
193        String dataHolderUid = null;
194        if (!includeImages && uid != null)
195        {
196            // create or update temporary content to serve images on the live workspaces
197            dataHolderUid = _useDataHolderContent(site, content, uid);
198        }
199        
200        try
201        {
202            sendNewsletter((DefaultWebContent) content, ImmutableMap.of(recipientEmail, "#token#"), dataHolderUid);
203        }
204        catch (IOException e)
205        {
206            getLogger().error("Unable to send the newsletter", e);
207            return false;
208        }
209        
210        return true;
211    }
212    
213    private String _useDataHolderContent(Site site, WebContent realContent, String uid)
214    {
215        try
216        {
217            if (!(realContent instanceof WorkflowAwareAmetysObject))
218            {
219                return null;
220            }
221            
222            Node realContentNode = ((WorkflowAwareAmetysObject) realContent).getNode();
223            NodeIterator realContentDataNodes = null;
224            try
225            {
226                Node realContentDataNode = realContentNode.getNode(RepositoryConstants.NAMESPACE_PREFIX + ":content/data");
227                realContentDataNodes = realContentDataNode.getNodes();
228            }
229            catch (PathNotFoundException e)
230            {
231                // no image in the richtext, ignore
232            }
233            
234            
235            if (realContentDataNodes != null && realContentDataNodes.getSize() > 0)
236            {
237                return _createDataHolderContentWithNodes(site, realContent, uid, realContentDataNodes);
238            }
239        }
240        catch (RepositoryException e)
241        {
242            getLogger().error("A repository error occurred when creating the data holder temporary content, when sending a test newsletter", e);
243        }
244        
245        return null;
246    }
247
248    private String _createDataHolderContentWithNodes(Site site, WebContent realContent, String uid, NodeIterator realContentDataNodes) throws RepositoryException
249    {
250        Session liveSession = null;
251        try
252        {
253            liveSession = _repository.login(WebConstants.LIVE_WORKSPACE);
254            ModifiableTraversableAmetysObject siteContents = _resolver.resolveByPath(site.getPath() + "/" + RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":contents");
255            ModifiableTraversableAmetysObject liveSiteContents = _resolver.resolveById(siteContents.getId(), liveSession);
256            
257            String contentName = realContent.getName() + "-test-" + uid;
258            ModifiableWebContent dataHolderContent = null;
259            
260            if (!liveSiteContents.hasChild(contentName))
261            {
262                dataHolderContent = liveSiteContents.createChild(contentName, RepositoryConstants.NAMESPACE_PREFIX + ":defaultWebContent");
263                dataHolderContent.setTypes(new String[] {__NEWSLETTER_CONTENT_TYPE});
264                dataHolderContent.setTitle(realContent.getTitle());
265                dataHolderContent.setSiteName(realContent.getSiteName());
266                dataHolderContent.setLanguage(realContent.getLanguage());
267                dataHolderContent.setLastModified(realContent.getLastModified());
268            }
269            else
270            {
271                dataHolderContent = liveSiteContents.getChild(contentName);
272            }
273            
274            
275            if (dataHolderContent instanceof JCRAmetysObject)
276            {
277                Node contentNode = ((JCRAmetysObject) dataHolderContent).getNode();
278                Node dataHolderContentNode = _getOrCreateNode(contentNode, RepositoryConstants.NAMESPACE_PREFIX + ":content", "nt:folder");
279                Node dataHolderNode = _getOrCreateNode(dataHolderContentNode, "data", "nt:folder");
280                
281                NodeIterator children = dataHolderNode.getNodes();
282                while (children.hasNext())
283                {
284                    children.nextNode().remove();
285                }
286            
287                while (realContentDataNodes.hasNext())
288                {
289                    _copyNode(dataHolderNode, realContentDataNodes.nextNode());
290                }
291                
292                dataHolderContent.saveChanges();
293            }
294            return dataHolderContent != null ? dataHolderContent.getId() : null;
295        }
296        finally
297        {
298            if (liveSession != null)
299            {
300                liveSession.logout();
301            }
302        }
303    }
304    
305    private Node _getOrCreateNode(Node node, String name, String type) throws RepositoryException
306    {
307        if (!node.hasNode(name))
308        {
309            return node.addNode(name, type);
310        }
311        else
312        {
313            return node.getNode(name);
314        }
315    }
316
317    private void _copyNode(Node parent, Node nodeToCopy) throws RepositoryException
318    {
319        Node newNode = parent.addNode(nodeToCopy.getName(), nodeToCopy.getPrimaryNodeType().getName());
320        
321        Predicate ignoreProtectedProperties = AmetysPredicateUtils.ignoreProtectedProperties(PredicateUtils.truePredicate());
322        
323        PropertyIterator properties = nodeToCopy.getProperties();
324        while (properties.hasNext())
325        {
326            Property property = properties.nextProperty();
327            if (ignoreProtectedProperties.evaluate(property))
328            {
329                if (property.getDefinition().isMultiple())
330                {
331                    newNode.setProperty(property.getName(), property.getValues());
332                }
333                else
334                {
335                    newNode.setProperty(property.getName(), property.getValue());
336                }
337            }
338        }
339        
340        NodeIterator nodeChildrenToCopy = nodeToCopy.getNodes();
341        while (nodeChildrenToCopy.hasNext())
342        {
343            _copyNode(newNode, nodeChildrenToCopy.nextNode());
344        }
345    }
346
347    /**
348     * Send the newsletter to the recipients
349     * @param content The newsletter
350     * @param recipients The recipients of the newsletter
351     * @throws IOException If an error occurred 
352     */
353    public void sendNewsletter(DefaultWebContent content, Map<String, String> recipients) throws IOException
354    {
355        sendNewsletter(content, recipients, null);
356    }
357    
358    /**
359     * Send the newsletter to the recipients
360     * @param content The newsletter
361     * @param recipients The recipients of the newsletter
362     * @param dataHolderId The content to use as a data holder proxy for images. Can be null
363     * @throws IOException If an error occurred 
364     */
365    public void sendNewsletter(DefaultWebContent content, Map<String, String> recipients, String dataHolderId) throws IOException
366    {
367        String subject = _getSubject (content);
368        String htmlBody = _getBodyAsHtml(content, dataHolderId);
369        String textBody = _getBodyAsText(content, dataHolderId);
370        
371        Site site = content.getSite();
372
373        String sender = site.getValue("newsletter-mail-sender");
374        
375        // Send the mail
376        SendMailEngine sendEngine = new SendMailEngine();
377        sendEngine.parameterize(subject, htmlBody, textBody, recipients, sender);
378        
379        new Thread(sendEngine).start();
380    }
381
382    /**
383     * Get the newsletter mail subject
384     * @param content The content
385     * @return The subject
386     */
387    protected String _getSubject (DefaultWebContent content)
388    {
389        String subject = "[" + content.getSite().getTitle() + "] ";
390        // TODO NEWATTRIBUTEAPI_CONTENT: There is no attribute category on newsletter content. See how to deal with this metadata
391        String categoryId = content.getMetadataHolder().getString("category");
392        Category category = getCategory(categoryId);
393        if (category != null)
394        {
395            subject += category.getTitle().getLabel() + " - ";
396        }
397        subject += content.getTitle();
398        
399        return subject;
400    }
401    
402    /**
403     * Get the newsletter HTML body
404     * @param content The content
405     * @param dataHolderId The data holder content to use as proxy images
406     * @return The body 
407     * @throws IOException if an I/O error occurred
408     */
409    protected String _getBodyAsHtml (DefaultWebContent content, String dataHolderId) throws IOException
410    {
411        SitemapSource src = null;
412        Request request = ContextHelper.getRequest(_context);
413        
414        Site site = content.getSite();
415        boolean includeImages = site.getValue("newsletter-mail-include-images", false, false);
416        
417        if (includeImages)
418        {
419            request.setAttribute("forceBase64Encoding", true);
420        }
421        
422        RenderingContext renderingContext = _renderingContextHandler.getRenderingContext();
423        try
424        {
425            _renderingContextHandler.setRenderingContext(RenderingContext.FRONT);
426        
427            String uri = "cocoon://_content.mail?contentId=" + content.getId() + "&site=" + content.getSiteName() + "&lang=" + content.getLanguage() + "&_contextPath=" + content.getSite().getUrl();
428            if (StringUtils.isNotEmpty(dataHolderId))
429            {
430                uri += "&useDataHolderContent=" + dataHolderId;
431            }
432            src = (SitemapSource) _sourceResolver.resolveURI(uri);
433            Reader reader = new InputStreamReader(src.getInputStream(), "UTF-8");
434            return IOUtils.toString(reader);
435        }
436        finally
437        {
438            _sourceResolver.release(src);
439            _renderingContextHandler.setRenderingContext(renderingContext);
440            request.removeAttribute("forceBase64Encoding");
441        }
442    }
443    
444    /**
445     * Get the newsletter text body
446     * @param content The content
447     * @param dataHolderId The data holder content to use as proxy images
448     * @return The body 
449     * @throws IOException if an I/O error occurred
450     */
451    protected String _getBodyAsText (DefaultWebContent content, String dataHolderId) throws IOException
452    {
453        SitemapSource src = null;
454        
455        RenderingContext renderingContext = _renderingContextHandler.getRenderingContext();
456        try
457        {
458            _renderingContextHandler.setRenderingContext(RenderingContext.FRONT);
459            
460            String uri = "cocoon://_content.text?contentId=" + content.getId() + "&site=" + content.getSiteName() + "&lang=" + content.getLanguage() + "&_contextPath=" + content.getSite().getUrl();
461            if (StringUtils.isNotEmpty(dataHolderId))
462            {
463                uri += "&useDataHolderContent=" + dataHolderId;
464            }
465            src = (SitemapSource) _sourceResolver.resolveURI(uri);
466            Reader reader = new InputStreamReader(src.getInputStream(), "UTF-8");
467            return IOUtils.toString(reader);
468        }
469        finally
470        {
471            _sourceResolver.release(src);
472            _renderingContextHandler.setRenderingContext(renderingContext);
473        }
474    }
475    
476    /**
477     * Get a category
478     * @param categoryID The category id
479     * @return The category
480     */
481    public Category getCategory (String categoryID)
482    {
483        Set<String> ids = _categoryProviderEP.getExtensionsIds();
484        for (String id : ids)
485        {
486            CategoryProvider provider = _categoryProviderEP.getExtension(id);
487            if (!categoryID.startsWith("provider_") && provider.hasCategory(categoryID))
488            {
489                return provider.getCategory(categoryID);
490            }
491        }
492        
493        return null;
494    }
495    
496
497    /**
498     * Remove the test newsletter if it exists in live workspace
499     * @param content The content
500     * @param site The site of the content
501     * @throws RepositoryException If an error occurred
502     */
503    public void removeTestNewsletter(WebContent content, Site site) throws RepositoryException
504    {
505        if (content.getMetadataHolder().hasMetadata(__TEST_UNIQUE_ID_METADATA))
506        {
507            Session liveSession = null;
508            try
509            {
510                String testUniqueId = content.getMetadataHolder().getString(__TEST_UNIQUE_ID_METADATA);
511                liveSession = _repository.login(WebConstants.LIVE_WORKSPACE);
512                ModifiableTraversableAmetysObject siteContents = _resolver.resolveByPath(site.getPath() + "/" + RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":contents");
513                ModifiableTraversableAmetysObject liveSiteContents = _resolver.resolveById(siteContents.getId(), liveSession);
514                
515                String contentName = content.getName() + "-test-" + testUniqueId;
516                
517                if (liveSiteContents.hasChild(contentName))
518                {
519                    AmetysObject child = liveSiteContents.getChild(contentName);
520                    if (child instanceof RemovableAmetysObject)
521                    {
522                        ((RemovableAmetysObject) child).remove();
523                        liveSiteContents.saveChanges();
524                    }
525                }
526                
527                ((ModifiableCompositeMetadata) content.getMetadataHolder()).removeMetadata(__TEST_UNIQUE_ID_METADATA);
528            }
529            finally
530            {
531                if (liveSession != null)
532                {
533                    liveSession.logout();
534                }
535            }
536        }
537    }
538}