001/*
002 *  Copyright 2019 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.core.util.mail;
017
018import java.io.File;
019import java.io.IOException;
020import java.io.InputStream;
021import java.util.ArrayList;
022import java.util.Collection;
023import java.util.Collections;
024import java.util.Date;
025import java.util.HashMap;
026import java.util.LinkedList;
027import java.util.List;
028import java.util.Map;
029import java.util.Map.Entry;
030import java.util.Optional;
031import java.util.Properties;
032import java.util.StringTokenizer;
033import java.util.concurrent.ExecutorService;
034import java.util.concurrent.Executors;
035import java.util.concurrent.ThreadFactory;
036import java.util.regex.Matcher;
037import java.util.regex.Pattern;
038
039import org.apache.avalon.framework.activity.Disposable;
040import org.apache.commons.lang3.StringUtils;
041import org.apache.commons.lang3.tuple.Pair;
042import org.jsoup.Jsoup;
043import org.jsoup.nodes.Document;
044import org.jsoup.nodes.Element;
045import org.jsoup.select.Elements;
046import org.jsoup.select.Selector;
047import org.slf4j.Logger;
048import org.slf4j.LoggerFactory;
049
050import org.ametys.runtime.config.Config;
051
052import jakarta.activation.DataHandler;
053import jakarta.mail.Message;
054import jakarta.mail.MessagingException;
055import jakarta.mail.Multipart;
056import jakarta.mail.Part;
057import jakarta.mail.Session;
058import jakarta.mail.Transport;
059import jakarta.mail.internet.InternetAddress;
060import jakarta.mail.internet.MimeBodyPart;
061import jakarta.mail.internet.MimeMessage;
062import jakarta.mail.internet.MimeMultipart;
063import jakarta.mail.internet.MimePart;
064import jakarta.mail.internet.PreencodedMimeBodyPart;
065
066/**
067 * Helper for sending mail
068 */
069public final class SendMailHelper implements Disposable
070{
071    /** Regexp to validate an email */
072    public static final String EMAIL_VALIDATION_REGEXP = "^.+@.+$"; // No you cannot do better
073    /** Regexp to validate an email */
074    public static final Pattern EMAIL_VALIDATION = Pattern.compile(EMAIL_VALIDATION_REGEXP);
075    
076    private static final Logger __LOGGER = LoggerFactory.getLogger(SendMailHelper.class);
077    private static final ExecutorService __SINGLE_THREAD_EXECUTOR = Executors.newSingleThreadExecutor(new MailSenderThreadFactory());
078
079    private SendMailHelper ()
080    {
081        // Nothing
082    }
083
084    @Override
085    public void dispose()
086    {
087        __SINGLE_THREAD_EXECUTOR.shutdownNow();
088    }
089    
090    private static class CssRule implements Comparable<CssRule>
091    {
092        private String _selector;
093        private String _properties;
094        private CssSpecificity _specificity;
095        
096        /**
097         * CSSRule constructor
098         * @param selector css selector
099         * @param properties css properties for this rule
100         * @param positionIdx The rules declaration index
101         */
102        public CssRule(String selector, String properties, int positionIdx)
103        {
104            _selector = selector;
105            _properties = properties;
106            _specificity = new CssSpecificity(_selector, positionIdx);
107        }
108        
109        /**
110         * Selector getter
111         * @return the selector
112         */
113        public String getSelector()
114        {
115            return _selector;
116        }
117        
118        /**
119         * Properties getter
120         * @return the properties
121         */
122        public String getProperties()
123        {
124            return _properties;
125        }
126        
127        public int compareTo(CssRule r)
128        {
129            return _specificity.compareTo(r._specificity);
130        }
131    }
132    
133    private static class CssSpecificity implements Comparable<CssSpecificity>
134    {
135        /** Attribute selectors pattern for CSS specificity processing */
136        private static final Pattern __CSS_SPECIFICITY_ATTR_PATTERN = Pattern.compile("(\\[[^\\]]+\\])");
137        /** ID selectors pattern for CSS specificity processing */
138        private static final Pattern __CSS_SPECIFICITY_ID_PATTERN = Pattern.compile("(#[^\\s\\+>~\\.\\[:]+)");
139        /** Class selectors pattern for CSS specificity processing */
140        private static final Pattern __CSS_SPECIFICITY_CLASS_PATTERN = Pattern.compile("(\\.[^\\s\\+>~\\.\\[:]+)");
141        /** Pseudo-element selectors pattern for CSS specificity processing */
142        private static final Pattern __CSS_SPECIFICITY_PSEUDO_ELEMENT_PATTERN = Pattern.compile("(::[^\\s\\+>~\\.\\[:]+|:first-line|:first-letter|:before|:after)", Pattern.CASE_INSENSITIVE);
143        /** Pseudo-class (with bracket) selectors pattern for CSS specificity processing */
144        private static final Pattern __CSS_SPECIFICITY_PSEUDO_CLASS_WITH_BRACKETS_PATTERN = Pattern.compile("(:[\\w-]+\\([^\\)]*\\))", Pattern.CASE_INSENSITIVE);
145        /** Pseudo-class selectors pattern for CSS specificity processing */
146        private static final Pattern __CSS_SPECIFICITY_PSEUDO_CLASS_PATTERN = Pattern.compile("(:[^\\s\\+>~\\.\\[:]+)");
147        /** Element selectors pattern for CSS specificity processing */
148        private static final Pattern __CSS_SPECIFICITY_ELEMENT_PATTERN = Pattern.compile("([^\\s\\+>~\\.\\[:]+)");
149        
150        /** Specific :not pseudo-class selectors pattern for CSS specificity processing */
151        private static final Pattern __CSS_SPECIFICITY_PSEUDO_CLASS_NOT_PATTERN = Pattern.compile(":not\\(([^\\)]*)\\)");
152        /** Universal and separator characters pattern for CSS specificity processing */
153        private static final Pattern __CSS_SPECIFICITY_UNIVERSAL_AND_SEPARATOR_PATTERN = Pattern.compile("[\\*\\s\\+>~]");
154
155        
156        private int[] _weights;
157        
158        public CssSpecificity(String selector, int positionIdx)
159        {
160            // Position index is used to differentiate equality cases
161            // -> latest declaration should be the one applied
162            _weights = new int[]{0, 0, 0, 0, positionIdx};
163            
164            String input = selector;
165            
166            // This part is loosely based on https://github.com/keeganstreet/specificity
167            
168            // Remove :not pseudo-class but leave its argument
169            input = __CSS_SPECIFICITY_PSEUDO_CLASS_NOT_PATTERN.matcher(input).replaceAll(" $1 ");
170            
171            // The following regular expressions assume that selectors matching the preceding regular expressions have been removed
172            input = _countReplaceAll(__CSS_SPECIFICITY_ATTR_PATTERN, input, 2);
173            input = _countReplaceAll(__CSS_SPECIFICITY_ID_PATTERN, input, 1);
174            input = _countReplaceAll(__CSS_SPECIFICITY_CLASS_PATTERN, input, 2);
175            input = _countReplaceAll(__CSS_SPECIFICITY_PSEUDO_ELEMENT_PATTERN, input, 3);
176            // A regex for pseudo classes with brackets - :nth-child(), :nth-last-child(), :nth-of-type(), :nth-last-type(), :lang()
177            input = _countReplaceAll(__CSS_SPECIFICITY_PSEUDO_CLASS_WITH_BRACKETS_PATTERN, input, 2);
178            // A regex for other pseudo classes, which don't have brackets
179            input = _countReplaceAll(__CSS_SPECIFICITY_PSEUDO_CLASS_PATTERN, input, 2);
180            
181            // Remove universal selector and separator characters
182            input = __CSS_SPECIFICITY_UNIVERSAL_AND_SEPARATOR_PATTERN.matcher(input).replaceAll(" ");
183            
184            _countReplaceAll(__CSS_SPECIFICITY_ELEMENT_PATTERN, input, 3);
185        }
186        
187        private String _countReplaceAll(Pattern pattern, String selector, int sIndex)
188        {
189            Matcher m = pattern.matcher(selector);
190            StringBuffer sb = new StringBuffer();
191            
192            while (m.find())
193            {
194                // Increment desired weight counter
195                _weights[sIndex]++;
196                
197                // Replace matched selector part with whitespace
198                m.appendReplacement(sb, " ");
199            }
200            
201            m.appendTail(sb);
202            
203            return sb.toString();
204        }
205        
206        public int compareTo(CssSpecificity o)
207        {
208            for (int i = 0; i < _weights.length; i++)
209            {
210                if (_weights[i] != o._weights[i])
211                {
212                    return _weights[i] - o._weights[i];
213                }
214            }
215            
216            return 0;
217        }
218    }
219    
220    /**
221     * Creates a new mail builder.
222     * @return the newly created {@link MailBuilder}.
223     */
224    public static MailBuilder newMail()
225    {
226        return new MailBuilder(__LOGGER, __SINGLE_THREAD_EXECUTOR);
227    }
228    
229    /*
230     * This method inline css in &lt;style&gt; tags directly in the appropriates tags. e.g. : &lt;style&gt;h1 {color: red;}&lt;/style&gt; &lt;h1&gt;a&lt;/h1&gt; becomes &lt;h1 style="color: red"&gt;a&lt;/h1&gt;
231     */
232    public static String inlineCSS(String html)
233    {
234        List<CssRule> rules = new LinkedList<>();
235        
236        Document doc = Jsoup.parse(html); 
237        Elements els = doc.select("style");
238
239        for (Element e : els) 
240        { 
241            String styleRules = e.getAllElements().get(0).data();
242            styleRules = styleRules.replaceAll("\t|\r|\n", "").replaceAll("<!--", "").replaceAll("-->", "");
243
244            styleRules = _removeComments(styleRules);
245
246            styleRules = styleRules.trim();
247
248            StringTokenizer st = new StringTokenizer(styleRules, "{}"); 
249            while (st.countTokens() > 1) 
250            { 
251                String selectors = st.nextToken();
252                String properties = st.nextToken();
253
254                String[] selector = selectors.split(",");
255                for (String s : selector)
256                {
257                    if (StringUtils.isNotBlank(s))
258                    {
259                        rules.add(new CssRule(s.trim(), properties, rules.size()));
260                    }
261                }
262            } 
263            e.remove(); 
264        }
265        
266        // Sort rules by specificity
267        Collections.sort(rules, Collections.reverseOrder());
268    
269        for (CssRule rule : rules)
270        {
271            try
272            {
273                Elements selectedElements = doc.select(rule.getSelector());
274                for (Element selElem : selectedElements)
275                {
276                    String oldProperties = selElem.attr("style");
277                    selElem.attr("style", oldProperties.length() > 0 ? concatenateProperties(oldProperties, rule.getProperties()) : rule.getProperties());
278                }
279            }
280            catch (Selector.SelectorParseException ex)
281            {
282                __LOGGER.error("Cannot inline CSS with rule \"" + rule.getSelector() + "\".Ignoring this rule and continuing.", ex);
283            }
284        }
285        
286        return doc.toString();
287    }
288
289    private static String _removeComments(String styleRules)
290    {
291        int i = styleRules.indexOf("/*");
292        int j = styleRules.indexOf("*/");
293        
294        if (i >= 0 && j > i)
295        {
296            return styleRules.substring(0, i) + _removeComments(styleRules.substring(j + 2));
297        }
298        
299        return styleRules;
300    }
301    
302    private static String concatenateProperties(String oldProp, String newProp) 
303    { 
304        String between = "";
305        if (!newProp.endsWith(";"))
306        {
307            between += ";";
308        }
309        
310        return newProp + between + oldProp.trim(); // The existing (old) properties should take precedence. 
311    } 
312    
313    /**
314     * Implements the builder pattern for creating and sending emails.
315     */
316    public static class MailBuilder
317    {
318        private Logger _logger;
319        private ExecutorService _executor;
320        
321        private boolean _async;
322
323        private String _subject;
324        private String _htmlBody;
325        private String _textBody;
326        private Collection<File> _attachments;
327        private Collection<NamedStream> _streamAttachments;
328        private List<String> _recipients;
329        private String _sender;
330        private List<String> _cc;
331        private List<String> _bcc;
332        private boolean _deliveryReceipt;
333        private boolean _readReceipt;
334        private String _host;
335        private long _port = -1;
336        private String _securityProtocol;
337        private String _user;
338        private String _password;
339        private boolean _singleEmail;
340        private List<String> _errorReport;
341        private boolean _inlineCSS = true;
342        private boolean _embedBase64Resources = true;
343        private Map<String, Pair<String, String>> _embeddedResource;
344        
345        
346        MailBuilder(Logger logger, ExecutorService executor)
347        {
348            _logger = logger;
349            _executor = executor;
350        }
351        
352        /**
353         * If the mail should be sent asynchronously or not.
354         * @param async the asynchronous flag
355         * @return this builder
356         */
357        public MailBuilder withAsync(boolean async)
358        {
359            _async = async;
360            return this;
361        }
362        
363        /**
364         * Set the mail subject. This parameter is mandatory.
365         * @param subject the mail subject
366         * @return this builder
367         */
368        public MailBuilder withSubject(String subject)
369        {
370            _subject = subject;
371            return this;
372        }
373        
374        /**
375         * Set the optional HTML body. May be null.
376         * @param htmlBody the HTML mail body
377         * @return this builder
378         */
379        public MailBuilder withHTMLBody(String htmlBody)
380        {
381            _htmlBody = htmlBody;
382            return this;
383        }
384        
385        /**
386         * Set the optional HTML body. May be null.
387         * @param htmlBody the HTML mail body
388         * @param embeddedResource a map representing the resource related to the htmlBody. can be null.
389         * the map map is composed of the resource URI identified by the content ID used in the html body
390         * @return this builder
391         */
392        public MailBuilder withHTMLBody(String htmlBody, Map<String, Pair<String, String>> embeddedResource)
393        {
394            _htmlBody = htmlBody;
395            _embeddedResource = embeddedResource;
396            return this;
397        }
398        
399        /**
400         * Set the optional text body. May be null.
401         * @param textBody the text mail body
402         * @return this builder
403         */
404        public MailBuilder withTextBody(String textBody)
405        {
406            _textBody = textBody;
407            return this;
408        }
409        
410        /**
411         * Set the optional attachments. May be null.
412         * @param attachments the file attachments
413         * @return this builder
414         */
415        public MailBuilder withAttachments(Collection<File> attachments)
416        {
417            _attachments = attachments;
418            return this;
419        }
420        
421        /**
422         * Set the optional attachments. May be null.
423         * @param attachments the input stream attachments
424         * @return this builder
425         */
426        public MailBuilder withAttachmentsAsStream(Collection<NamedStream> attachments)
427        {
428            _streamAttachments = attachments;
429            return this;
430        }
431        
432        /**
433         * Set the mail recipient
434         * @param recipient the recipient address
435         * @return this builder
436         */
437        public MailBuilder withRecipient(String recipient)
438        {
439            _recipients = List.of(recipient);
440            return this;
441        }
442        
443        /**
444         * Set the mail recipients
445         * @param recipients the recipients addresses
446         * @return this builder
447         */
448        public MailBuilder withRecipients(List<String> recipients)
449        {
450            _recipients = recipients;
451            return this;
452        }
453        
454        /**
455         * Set the mail sender.
456         * @param sender the mail sender
457         * @return this builder
458         */
459        public MailBuilder withSender(String sender)
460        {
461            _sender = sender;
462            return this;
463        }
464        
465        /**
466         * Set the carbon copy address list. May be null.
467         * @param cc the cc address list
468         * @return this builder
469         */
470        public MailBuilder withCc(List<String> cc)
471        {
472            _cc = cc;
473            return this;
474        }
475        
476        /**
477         * Set the blind carbon copy address list. May be null.
478         * @param bcc the bcc address list
479         * @return this builder
480         */
481        public MailBuilder withBcc(List<String> bcc)
482        {
483            _bcc = bcc;
484            return this;
485        }
486        
487        /**
488         * Set to true to get a delivery receipt
489         * @param deliveryReceipt the delivery receipt flag
490         * @return this builder
491         */
492        public MailBuilder withDeliveryReceipt(boolean deliveryReceipt)
493        {
494            _deliveryReceipt = deliveryReceipt;
495            return this;
496        }
497        
498        /**
499         * Set to true to get a read receipt
500         * @param readReceipt the read receipt flag
501         * @return this builder
502         */
503        public MailBuilder withReadReceipt(boolean readReceipt)
504        {
505            _readReceipt = readReceipt;
506            return this;
507        }
508        
509        /**
510         * Set the SMTP host. Defaults to the value provided in the general configuration.
511         * @param host the SMTP host
512         * @return this builder
513         */
514        public MailBuilder withHost(String host)
515        {
516            _host = host;
517            return this;
518        }
519        
520        /**
521         * Set the SMTP port. Defaults to the value provided in the general configuration.
522         * @param port the SMTP port
523         * @return this builder
524         */
525        public MailBuilder withPort(long port)
526        {
527            _port = port;
528            return this;
529        }
530        
531        /**
532         * Set the security protocol ("starttls", "tlsssl"). May be null.
533         * @param securityProtocol the security protocol
534         * @return this builder
535         */
536        public MailBuilder withSecurityProtocol(String securityProtocol)
537        {
538            _securityProtocol = securityProtocol;
539            return this;
540        }
541        
542        /**
543         * Set the SMTP user. Defaults to the value provided in the general configuration.
544         * @param user the SMTP user
545         * @return this builder
546         */
547        public MailBuilder withUser(String user)
548        {
549            _user = user;
550            return this;
551        }
552        
553        /**
554         * Set the SMTP password. Defaults to the value provided in the general configuration.
555         * @param password the STMP password
556         * @return this builder
557         */
558        public MailBuilder withPassword(String password)
559        {
560            _password = password;
561            return this;
562        }
563        
564        /**
565         * Set to true if a single email with all recipients should be sent. Defaults to false. 
566         * @param singleEmail the single email flag
567         * @return this builder
568         */
569        public MailBuilder withSingleEmail(boolean singleEmail)
570        {
571            _singleEmail = singleEmail;
572            return this;
573        }
574        
575        /**
576         * Set a list to get the send error report. The list will be populated with each recipient which the send failed.
577         * @param errorReport teh error report list to fill
578         * @return this builder
579         */
580        public MailBuilder withErrorReport(List<String> errorReport)
581        {
582            _errorReport = errorReport;
583            return this;
584        }
585        
586        /**
587         * Set to true to inline CSS rules. 
588         * <br>Set to false to preserve provided HTML code as is.
589         * <br>Default to true for legacy purposes.
590         * @param inlineCSS the inlining CSS flag
591         * @return this builder
592         */
593        public MailBuilder withInlineCSS(boolean inlineCSS)
594        {
595            _inlineCSS = inlineCSS;
596            return this;
597        }
598        
599        /**
600         * Set to true to embed base64 resources as attachment. 
601         * <br>Set to false to preserve provided HTML code as is.
602         * <br>Default to true.
603         * @param embedBase64Resources the embedding flag
604         * @return this builder
605         */
606        public MailBuilder withEmbeddedBase64Resources(boolean embedBase64Resources)
607        {
608            _embedBase64Resources = embedBase64Resources;
609            return this;
610        }
611        
612        /**
613         * Actually send mail.
614         * @throws MessagingException If an error occurred while preparing or sending email
615         * @throws IOException if an error occurs while attaching a file.
616         */
617        public void sendMail() throws MessagingException, IOException
618        {
619            Optional<Config> config = Optional.ofNullable(Config.getInstance());
620            
621            MailSender mailSender = new MailSender(_logger, 
622                                                   _subject, 
623                                                   _textBody, 
624                                                   _prepareHTMLBody(_htmlBody), 
625                                                   _embeddedResource, 
626                                                   _attachments,
627                                                   _streamAttachments,
628                                                   _recipients, 
629                                                   StringUtils.defaultIfEmpty(_sender, config.map(c -> c.<String>getValue("smtp.mail.from")).orElse(null)), 
630                                                   _cc, 
631                                                   _bcc, 
632                                                   _deliveryReceipt, 
633                                                   _readReceipt, 
634                                                   StringUtils.defaultIfEmpty(_host, config.map(c -> c.<String>getValue("smtp.mail.host")).orElse(null)), 
635                                                   _port > 0 ? _port : config.map(c -> c.<Long>getValue("smtp.mail.port")).orElse(0L), 
636                                                   StringUtils.defaultIfEmpty(_securityProtocol, config.map(c -> c.<String>getValue("smtp.mail.security.protocol")).orElse(null)), 
637                                                   StringUtils.defaultIfEmpty(_user, config.map(c -> c.<String>getValue("smtp.mail.user")).orElse(null)), 
638                                                   StringUtils.defaultIfEmpty(_password, config.map(c -> c.<String>getValue("smtp.mail.password")).orElse(null)), _singleEmail, _errorReport);
639            
640            if (!_async)
641            {
642                mailSender.sendMail();
643            }
644            else
645            {
646                _executor.execute(mailSender);
647            }
648        }
649
650        private String _prepareHTMLBody(String htmlBody)
651        {
652            String result = htmlBody;
653            if (result != null)
654            {
655                if (_inlineCSS)
656                {
657                    result = inlineCSS(result);
658                }
659                
660                if (_embedBase64Resources)
661                {
662                    result = _embedBase64Resources(result);
663                }
664            }
665            return result;
666        }
667        
668        private String _embedBase64Resources(String html)
669        {
670            // parse the html to retrieve all base64 resources
671            // remove it from the html
672            // replace it with a cid
673            // add the base64 resource to the embeddedResource with it's cid
674            
675            Pattern pattern = Pattern.compile("\"data:(\\w+/\\w+);base64,(\\S*)\"");
676            Matcher matcher = pattern.matcher(html);
677            
678            if (_embeddedResource == null)
679            {
680                _embeddedResource = new HashMap<>();
681            }
682            
683            return matcher.replaceAll(match ->
684            {
685                String mimeType = match.group(1);
686                String base64Resource = match.group(2);
687                String hashCode = Integer.toString(base64Resource.hashCode());
688                _embeddedResource.putIfAbsent(hashCode, Pair.of(mimeType, base64Resource));
689                return "\"cid:" + hashCode + "\"";
690            });
691        }
692    }
693    
694    /**
695     * A record representing a stream with name and mime type
696     *
697     * @param inputStream the input stream
698     * @param name the name
699     * @param mimeType the mime type
700     */
701    public record NamedStream(InputStream inputStream, String name, String mimeType) { /* */ }
702    
703    private static class MailSender implements Runnable
704    {
705        private String _subject;
706        private String _textBody;
707        private String _htmlBody;
708        private Map<String, Pair<String, String>> _embeddedAttachments;
709        private Collection<File> _attachments;
710        private Collection<NamedStream> _streamAttachments;
711        private List<String> _recipients;
712        private String _sender;
713        private List<String> _cc;
714        private List<String> _bcc;
715        private boolean _deliveryReceipt;
716        private boolean _readReceipt;
717        private String _host;
718        private long _port;
719        private String _securityProtocol;
720        private String _user;
721        private String _password;
722        private boolean _singleEmail;
723        private List<String> _errorReport;
724        private Logger _logger;
725
726        /**
727         * Initialize the mail sender with email parameters
728         * @param logger The logger
729         * @param subject The mail subject
730         * @param textBody The text mail body. Can be null.
731         * @param htmlBody The HTML mail body. Can be null.
732         * @param embeddedResources a map representing the resources to embed. the key is the content id. the value is a pair of the mimeType and the base64 resource.
733         * @param attachments the file attachments. Can be null.
734         * @param streamAttachments the stream attachments. Can be null.
735         * @param recipients The recipients addresses
736         * @param sender The sender address. Can be null when called by MailChecker.
737         * @param cc Carbon copy address list. Can be null.
738         * @param bcc Blind carbon copy address list. Can be null.
739         * @param deliveryReceipt true to request that the receiving mail server send a notification when the mail is received.
740         * @param readReceipt true to request that the receiving mail client send a notification when the person opens the mail.
741         * @param host The server mail host. Can be null when called by MailChecker.
742         * @param port The server port
743         * @param securityProtocol the security protocol to use when transporting the email
744         * @param user The user name
745         * @param password The user password
746         * @param singleEmail true if just one email is sent with all recipients. Otherwise, an email will be sent for each recipients.
747         * @param errorReport The error report list to get the recipients that failed. Can be null.
748         */
749        public MailSender(Logger logger, String subject, String textBody, String htmlBody, Map<String, Pair<String, String>> embeddedResources, Collection<File> attachments, Collection<NamedStream> streamAttachments, List<String> recipients, String sender, List<String> cc, List<String> bcc, boolean deliveryReceipt, boolean readReceipt, String host, long port, String securityProtocol, String user, String password, boolean singleEmail, List<String> errorReport)
750        {
751            _logger = logger;
752            _subject = subject;
753            _textBody = textBody;
754            _htmlBody = htmlBody;
755            _embeddedAttachments = embeddedResources;
756            _attachments = attachments;
757            _streamAttachments = streamAttachments;
758            _recipients = recipients;
759            _sender = sender;
760            _cc = cc;
761            _bcc = bcc;
762            _deliveryReceipt = deliveryReceipt;
763            _readReceipt = readReceipt;
764            _host = host;
765            _port = port;
766            _securityProtocol = securityProtocol;
767            _user = user;
768            _password = password;
769            _singleEmail = singleEmail;
770            _errorReport = errorReport;
771        }
772        
773        public void run()
774        {
775            try
776            {
777                sendMail();
778            }
779            catch (Exception e)
780            {
781                _logger.error("Unable to send mail with subject: {}", _subject, e);
782            }
783        }
784        
785        public void sendMail() throws MessagingException, IOException
786        {
787            List<InputStreamDataSource> dataSources = new ArrayList<>();
788            try
789            {
790                Properties props = new Properties();
791    
792                // Setup mail server
793                props.put("mail.smtp.host", _host);
794                props.put("mail.smtp.port", _port);
795                
796                // Security protocol
797                if (_securityProtocol.equals("starttls"))
798                {
799                    props.put("mail.smtp.starttls.enable", "true"); 
800                }
801                else if (_securityProtocol.equals("tlsssl"))
802                {
803                    props.put("mail.smtp.ssl.enable", "true");
804                }
805                
806                Session session = Session.getInstance(props, null);
807                
808                // Define message
809                MimeMessage message = new MimeMessage(session);
810                
811                if (_sender != null)
812                {
813                    message.setFrom(new InternetAddress(_sender));
814                }
815                
816                message.setSentDate(new Date());
817                message.setSubject(_subject, "UTF-8");
818                
819                if (_attachments == null && _streamAttachments == null)
820                {
821                    _setBodyPart(message);
822                }
823                else
824                {
825                    Multipart multipart = new MimeMultipart("mixed");
826                    
827                    MimeBodyPart bodyPart = new MimeBodyPart();
828                    _setBodyPart(bodyPart);
829                    multipart.addBodyPart(bodyPart);
830                    
831                    if (_attachments != null)
832                    {
833                        for (File attachment : _attachments)
834                        {
835                            MimeBodyPart fileBodyPart = new MimeBodyPart();
836                            fileBodyPart.attachFile(attachment);
837                            multipart.addBodyPart(fileBodyPart);
838                        }
839                    }
840                    
841                    if (_streamAttachments != null)
842                    {
843                        for (NamedStream attachment : _streamAttachments)
844                        {
845                            MimeBodyPart fileBodyPart = new MimeBodyPart();
846                            InputStreamDataSource fds = new InputStreamDataSource(attachment);
847                            dataSources.add(fds);
848                            fileBodyPart.setDataHandler(new DataHandler(fds));
849                            fileBodyPart.setFileName(attachment.name());
850                            fileBodyPart.setDisposition(Part.ATTACHMENT);
851                            multipart.addBodyPart(fileBodyPart);
852                        }
853                    }
854                    
855                    message.setContent(multipart);
856                }
857    
858                // Carbon copies
859                if (_cc != null)
860                {
861                    message.setRecipients(Message.RecipientType.CC, InternetAddress.parse(StringUtils.join(_cc, ','), false));
862                }
863    
864                // Blind carbon copies
865                if (_bcc != null)
866                {
867                    message.setRecipients(Message.RecipientType.BCC, InternetAddress.parse(StringUtils.join(_bcc, ','), false));
868                }
869                
870                // Delivery receipt : Return-Receipt-To
871                if (_deliveryReceipt)
872                {
873                    message.setHeader("Return-Receipt-To", _sender);
874                }
875                
876                // Read receipt : Disposition-Notification-To
877                if (_readReceipt)
878                {
879                    message.setHeader("Disposition-Notification-To", _sender);
880                }
881                
882                try (Transport tr = session.getTransport("smtp"))
883                {
884                    tr.connect(_host, (int) _port, StringUtils.trimToNull(_user), StringUtils.trimToNull(_password));
885                    
886                    _sendMail(message, tr);
887                }
888            }
889            finally
890            {
891                for (InputStreamDataSource dataSource : dataSources)
892                {
893                    dataSource.dispose();
894                }
895            }
896        }
897        
898        private void _setBodyPart(MimePart part) throws MessagingException
899        {
900            if (_textBody != null && _htmlBody != null)
901            {
902                Multipart bodyMultipart = new MimeMultipart("alternative");
903                
904                MimeBodyPart textPart = new MimeBodyPart();
905                bodyMultipart.addBodyPart(textPart);
906                _setTextBody(textPart);
907                
908                MimeBodyPart htmlPart = new MimeBodyPart();
909                bodyMultipart.addBodyPart(htmlPart);
910                _setHTMLBody(htmlPart);
911                
912                part.setContent(bodyMultipart);
913            }
914            else if (_textBody != null)
915            {
916                _setTextBody(part);
917            }
918            else if (_htmlBody != null)
919            {
920                _setHTMLBody(part);
921            }
922        }
923        
924        private void _setTextBody(MimePart part) throws MessagingException
925        {
926            part.setText(_textBody, "UTF-8");
927        }
928        
929        private void _setHTMLBody(MimePart part) throws MessagingException
930        {
931            if (_embeddedAttachments != null && _embeddedAttachments.size() > 0)
932            {
933                // create a new multipart related to store the html and the attachments
934                MimeMultipart relatedMultipart = new MimeMultipart("related");
935                part.setContent(relatedMultipart);
936                
937                MimeBodyPart htmlPart = new MimeBodyPart();
938                
939                relatedMultipart.addBodyPart(htmlPart);
940                htmlPart.setText(_htmlBody, "UTF-8", "html");
941                
942                for (Entry<String, Pair<String, String>> entry : _embeddedAttachments.entrySet())
943                {
944                    MimeBodyPart entryPart = new PreencodedMimeBodyPart("base64");
945                    // < and > for Gmail support
946                    entryPart.setContentID("<" + entry.getKey() + ">");
947                    entryPart.setDisposition(Part.INLINE);
948                    entryPart.setFileName(entry.getKey());
949                    entryPart.setContent(entry.getValue().getRight(), entry.getValue().getLeft());
950                    
951                    relatedMultipart.addBodyPart(entryPart);
952                }
953            }
954            else
955            {
956                part.setText(_htmlBody, "UTF-8", "html");
957            }
958        }
959        
960        private void _sendMail(MimeMessage message, Transport tr) throws MessagingException
961        {
962            if (_recipients != null && _recipients.size() > 0 && _sender != null)
963            {
964                if (_singleEmail)
965                {
966                    // Send one mail to multiple recipients
967                    message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(StringUtils.join(_recipients, ','), false));
968                    message.saveChanges();
969                    
970                    tr.sendMessage(message, message.getAllRecipients());
971                }
972                else
973                {
974                    // Send mail for each recipients
975                    for (String recipient : _recipients)
976                    {
977                        try
978                        {
979                            message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(recipient, false));
980                            message.saveChanges();
981                            tr.sendMessage(message, message.getAllRecipients());
982                        }
983                        catch (Exception e) 
984                        {
985                            if (_errorReport != null)
986                            {
987                                _errorReport.add(recipient);
988                            }
989                            _logger.error("Can't send mail with subject \"{}\" to the address {}", _subject, recipient, e);
990                        }
991                    }
992                }
993            }
994        }
995    }
996    
997    private static class MailSenderThreadFactory implements ThreadFactory
998    {
999        private ThreadFactory _defaultThreadFactory;
1000
1001        public MailSenderThreadFactory()
1002        {
1003            _defaultThreadFactory = Executors.defaultThreadFactory();
1004        }
1005        
1006        public Thread newThread(Runnable r)
1007        {
1008            Thread thread = _defaultThreadFactory.newThread(r);
1009            thread.setName("mail-sender-thread");
1010            thread.setDaemon(true);
1011            
1012            return thread;
1013        }
1014    }
1015}