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