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}