001/*
002 * (C) Copyright 2006-2018 Nuxeo (http://nuxeo.com/) and others.
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 *
016 * Contributors:
017 *     bstefanescu
018 */
019package org.nuxeo.ecm.automation.core.operations.notification;
020
021import static java.nio.charset.StandardCharsets.UTF_8;
022
023import java.io.IOException;
024import java.io.InputStream;
025import java.net.URL;
026import java.util.ArrayList;
027import java.util.Date;
028import java.util.List;
029import java.util.Map;
030
031import javax.mail.MessagingException;
032import javax.mail.Session;
033import javax.ws.rs.core.UriBuilder;
034
035import org.apache.commons.io.IOUtils;
036import org.apache.commons.logging.Log;
037import org.apache.commons.logging.LogFactory;
038import org.apache.commons.text.StringEscapeUtils;
039import org.nuxeo.ecm.automation.OperationContext;
040import org.nuxeo.ecm.automation.OperationException;
041import org.nuxeo.ecm.automation.core.Constants;
042import org.nuxeo.ecm.automation.core.annotations.Context;
043import org.nuxeo.ecm.automation.core.annotations.Operation;
044import org.nuxeo.ecm.automation.core.annotations.OperationMethod;
045import org.nuxeo.ecm.automation.core.annotations.Param;
046import org.nuxeo.ecm.automation.core.collectors.DocumentModelCollector;
047import org.nuxeo.ecm.automation.core.mail.Composer;
048import org.nuxeo.ecm.automation.core.mail.Mailer;
049import org.nuxeo.ecm.automation.core.mail.Mailer.Message;
050import org.nuxeo.ecm.automation.core.mail.Mailer.Message.AS;
051import org.nuxeo.ecm.automation.core.scripting.Scripting;
052import org.nuxeo.ecm.automation.core.util.StringList;
053import org.nuxeo.ecm.core.api.Blob;
054import org.nuxeo.ecm.core.api.DocumentModel;
055import org.nuxeo.ecm.core.api.NuxeoException;
056import org.nuxeo.ecm.core.api.PropertyException;
057import org.nuxeo.ecm.core.api.model.Property;
058import org.nuxeo.ecm.core.api.model.impl.ListProperty;
059import org.nuxeo.ecm.core.api.model.impl.MapProperty;
060import org.nuxeo.ecm.core.api.model.impl.primitives.BlobProperty;
061import org.nuxeo.ecm.platform.ec.notification.service.NotificationServiceHelper;
062import org.nuxeo.ecm.platform.rendering.api.RenderingException;
063import org.nuxeo.runtime.api.Framework;
064
065import freemarker.template.TemplateException;
066
067/**
068 * Save the session - TODO remove this?
069 *
070 * @author <a href="mailto:bs@nuxeo.com">Bogdan Stefanescu</a>
071 */
072@Operation(id = SendMail.ID, category = Constants.CAT_NOTIFICATION, label = "Send E-Mail", description = "Send an email using the input document to the specified recipients. You can use the HTML parameter to specify whether you message is in HTML format or in plain text. Also you can attach any blob on the current document to the message by using the comma separated list of xpath expressions 'files'. If you xpath points to a blob list all blobs in the list will be attached. Return back the input document(s). If rollbackOnError is true, the whole chain will be rollbacked if an error occurs while trying to send the email (for instance if no SMTP server is configured), else a simple warning will be logged and the chain will continue.", aliases = {
073        "Notification.SendMail" })
074public class SendMail {
075
076    protected static final Log log = LogFactory.getLog(SendMail.class);
077
078    public static final Composer COMPOSER = new Composer();
079
080    public static final String ID = "Document.Mail";
081
082    @Context
083    protected OperationContext ctx;
084
085    @Param(name = "from")
086    protected String from;
087
088    @Param(name = "to", required = false)
089    protected StringList to;
090
091    // Useful for tests.
092    protected Session mailSession;
093
094    /**
095     * @since 5.9.1
096     */
097    @Param(name = "cc", required = false)
098    protected StringList cc;
099
100    /**
101     * @since 5.9.1
102     */
103    @Param(name = "bcc", required = false)
104    protected StringList bcc;
105
106    /**
107     * @since 5.9.1
108     */
109    @Param(name = "replyto", required = false)
110    protected StringList replyto;
111
112    @Param(name = "subject")
113    protected String subject;
114
115    @Param(name = "message", widget = Constants.W_MAIL_TEMPLATE)
116    protected String message;
117
118    @Param(name = "HTML", required = false, values = { "false" })
119    protected boolean asHtml = false;
120
121    @Param(name = "files", required = false)
122    protected StringList blobXpath;
123
124    @Param(name = "rollbackOnError", required = false, values = { "true" })
125    protected boolean rollbackOnError = true;
126
127    /**
128     * @since 5.9.1
129     */
130    @Param(name = "Strict User Resolution", required = false)
131    protected boolean isStrict = true;
132
133    @Param(name = "viewId", required = false, values = { "view_documents" })
134    protected String viewId = "view_documents";
135
136    @OperationMethod(collector = DocumentModelCollector.class)
137    public DocumentModel run(DocumentModel doc)
138            throws TemplateException, RenderingException, OperationException, MessagingException, IOException {
139        send(doc);
140        return doc;
141    }
142
143    protected String getContent() throws OperationException, IOException {
144        message = message.trim();
145        if (message.startsWith("template:")) {
146            String name = message.substring("template:".length()).trim();
147            URL url = MailTemplateHelper.getTemplate(name);
148            if (url == null) {
149                throw new OperationException("No such mail template: " + name);
150            }
151            try (InputStream in = url.openStream()) {
152                return IOUtils.toString(in, UTF_8);
153            }
154        } else {
155            return StringEscapeUtils.unescapeHtml4(message);
156        }
157    }
158
159    protected void send(DocumentModel doc)
160            throws TemplateException, RenderingException, OperationException, MessagingException, IOException {
161        // TODO should sent one by one to each recipient? and have the template
162        // rendered for each recipient? Use: "mailto" var name?
163        try {
164            Map<String, Object> map = Scripting.initBindings(ctx);
165            // do not use document wrapper which is working only in mvel.
166            map.put("Document", doc);
167            map.put("docUrl",
168                    createDocUrlWithToken(MailTemplateHelper.getDocumentUrl(doc, viewId), (String) map.get("token")));
169            map.put("subject", subject);
170            map.put("to", to);
171            map.put("toResolved", MailBox.fetchPersonsFromList(to, isStrict));
172            map.put("from", from);
173            map.put("fromResolved", MailBox.fetchPersonsFromString(from, isStrict));
174            map.put("from", cc);
175            map.put("fromResolved", MailBox.fetchPersonsFromList(cc, isStrict));
176            map.put("from", bcc);
177            map.put("fromResolved", MailBox.fetchPersonsFromList(bcc, isStrict));
178            map.put("from", replyto);
179            map.put("fromResolved", MailBox.fetchPersonsFromList(replyto, isStrict));
180            map.put("viewId", viewId);
181            map.put("baseUrl", NotificationServiceHelper.getNotificationService().getServerUrlPrefix());
182            map.put("Runtime", Framework.getRuntime());
183            Mailer.Message msg = createMessage(doc, getContent(), map);
184            msg.setSubject(subject, "UTF-8");
185            msg.setSentDate(new Date());
186
187            addMailBoxInfo(msg);
188
189            msg.send();
190        } catch (NuxeoException | TemplateException | RenderingException | OperationException | MessagingException
191                | IOException e) {
192            if (rollbackOnError) {
193                throw e;
194            } else {
195                log.warn(String.format(
196                        "An error occured while trying to execute the %s operation, see complete stack trace below. Continuing chain since 'rollbackOnError' was set to false.",
197                        ID), e);
198            }
199        }
200    }
201
202    // Only visible for testing purposes
203    protected String createDocUrlWithToken(String documentUrl, String token) {
204        return token != null ? UriBuilder.fromUri(documentUrl).queryParam("token", token).build().toString()
205                : documentUrl;
206    }
207
208    /**
209     * @since 5.9.1
210     */
211    private void addMailBoxInfo(Mailer.Message msg) throws MessagingException {
212        List<MailBox> persons = MailBox.fetchPersonsFromString(from, isStrict);
213        addMailBoxInfoInMessageHeader(msg, AS.FROM, persons);
214
215        persons = MailBox.fetchPersonsFromList(to, isStrict);
216        addMailBoxInfoInMessageHeader(msg, AS.TO, persons);
217
218        persons = MailBox.fetchPersonsFromList(cc, isStrict);
219        addMailBoxInfoInMessageHeader(msg, AS.CC, persons);
220
221        persons = MailBox.fetchPersonsFromList(bcc, isStrict);
222        addMailBoxInfoInMessageHeader(msg, AS.BCC, persons);
223
224        if (replyto != null && !replyto.isEmpty()) {
225            msg.setReplyTo(null);
226            persons = MailBox.fetchPersonsFromList(replyto, isStrict);
227            addMailBoxInfoInMessageHeader(msg, AS.REPLYTO, persons);
228        }
229    }
230
231    /**
232     * @since 5.9.1
233     */
234    private void addMailBoxInfoInMessageHeader(Message msg, AS as, List<MailBox> persons) throws MessagingException {
235        for (MailBox person : persons) {
236            msg.addInfoInMessageHeader(person.toString(), as);
237        }
238    }
239
240    protected Mailer.Message createMessage(DocumentModel doc, String message, Map<String, Object> map)
241            throws MessagingException, TemplateException, RenderingException, IOException {
242        if (blobXpath == null) {
243            if (asHtml) {
244                return COMPOSER.newHtmlMessage(message, map);
245            } else {
246                return COMPOSER.newTextMessage(message, map);
247            }
248        } else {
249            List<Blob> blobs = new ArrayList<>();
250            for (String xpath : blobXpath) {
251                try {
252                    Property p = doc.getProperty(xpath);
253                    if (p instanceof BlobProperty) {
254                        getBlob(p.getValue(), blobs);
255                    } else if (p instanceof ListProperty) {
256                        for (Property pp : p) {
257                            getBlob(pp.getValue(), blobs);
258                        }
259                    } else if (p instanceof MapProperty) {
260                        for (Property sp : ((MapProperty) p).values()) {
261                            getBlob(sp.getValue(), blobs);
262                        }
263                    } else {
264                        Object o = p.getValue();
265                        if (o instanceof Blob) {
266                            blobs.add((Blob) o);
267                        }
268                    }
269                } catch (PropertyException pe) {
270                    log.error("Error while fetching blobs: " + pe.getMessage());
271                    log.debug(pe, pe);
272                }
273            }
274            return COMPOSER.newMixedMessage(message, map, asHtml ? "html" : "plain", blobs);
275        }
276    }
277
278    /**
279     * @since 5.7
280     * @param o: the object to introspect to find a blob
281     * @param blobs: the Blob list where the blobs are put during property introspection
282     */
283    @SuppressWarnings("unchecked")
284    private void getBlob(Object o, List<Blob> blobs) {
285        if (o instanceof List) {
286            for (Object item : (List<Object>) o) {
287                getBlob(item, blobs);
288            }
289        } else if (o instanceof Map) {
290            for (Object item : ((Map<String, Object>) o).values()) {
291                getBlob(item, blobs);
292            }
293        } else if (o instanceof Blob) {
294            blobs.add((Blob) o);
295        }
296
297    }
298}