001/*
002 *  Copyright 2022 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.forms.helper;
017
018import java.io.IOException;
019import java.io.InputStreamReader;
020import java.io.Reader;
021import java.util.ArrayList;
022import java.util.Collection;
023import java.util.HashMap;
024import java.util.List;
025import java.util.Map;
026import java.util.Objects;
027import java.util.Optional;
028import java.util.stream.Collectors;
029
030import org.apache.avalon.framework.component.Component;
031import org.apache.avalon.framework.context.Context;
032import org.apache.avalon.framework.context.ContextException;
033import org.apache.avalon.framework.context.Contextualizable;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.cocoon.components.ContextHelper;
038import org.apache.cocoon.environment.Request;
039import org.apache.commons.io.IOUtils;
040import org.apache.commons.lang.StringUtils;
041import org.apache.commons.lang3.ArrayUtils;
042import org.apache.excalibur.source.Source;
043import org.apache.excalibur.source.SourceResolver;
044
045import org.ametys.cms.data.File;
046import org.ametys.core.ui.Callable;
047import org.ametys.core.ui.mail.StandardMailBodyHelper;
048import org.ametys.core.user.User;
049import org.ametys.core.user.UserIdentity;
050import org.ametys.core.user.UserManager;
051import org.ametys.core.util.I18nUtils;
052import org.ametys.core.util.mail.SendMailHelper;
053import org.ametys.core.util.mail.SendMailHelper.MailBuilder;
054import org.ametys.core.util.mail.SendMailHelper.NamedStream;
055import org.ametys.plugins.forms.dao.FormDAO;
056import org.ametys.plugins.forms.question.FormQuestionType;
057import org.ametys.plugins.forms.question.sources.AbstractSourceType;
058import org.ametys.plugins.forms.question.sources.ManualWithEmailSourceType;
059import org.ametys.plugins.forms.question.sources.UsersSourceType;
060import org.ametys.plugins.forms.question.types.ChoicesListQuestionType;
061import org.ametys.plugins.forms.question.types.FileQuestionType;
062import org.ametys.plugins.forms.question.types.SimpleTextQuestionType;
063import org.ametys.plugins.forms.repository.Form;
064import org.ametys.plugins.forms.repository.FormEntry;
065import org.ametys.plugins.forms.repository.FormQuestion;
066import org.ametys.plugins.repository.AmetysObjectResolver;
067import org.ametys.runtime.config.Config;
068import org.ametys.runtime.i18n.I18nizableText;
069import org.ametys.runtime.plugin.component.AbstractLogEnabled;
070import org.ametys.web.repository.page.Page;
071import org.ametys.web.repository.site.Site;
072
073import jakarta.mail.MessagingException;
074
075/**
076 * The helper for form mail dialog
077 */
078public class FormMailHelper extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
079{
080
081    /** Avalon ROLE. */
082    public static final String ROLE = FormMailHelper.class.getName();
083   
084    /** The param key for read restriction enable */
085    public static final String READ_RESTRICTION_ENABLE = "read-restriction-enable";
086    
087    /** The value for entry user in the receiver combobox */
088    public static final String RECEIVER_COMBOBOX_ENTRY_USER_VALUE = "entry-user";
089    
090    /** The empty value in the receiver combobox */
091    public static final String RECEIVER_COMBOBOX_INPUT_ONLY = "input-only";
092
093    /** The request key to ignore right */
094    public static final String IGNORE_RIGHT_KEY = "ignore-right";
095
096    /** Pattern for adding entry in acknowledgement of receipt if present in body */
097    protected static final String _FORM_ENTRY_PATTERN = "{form}";
098    
099    /** Ametys object resolver. */
100    protected AmetysObjectResolver _resolver;
101    
102    /** I18n Utils */
103    protected I18nUtils _i18nUtils;
104    
105    /** The user manager */
106    protected UserManager _userManager;
107    
108    /** The source resolver. */
109    protected SourceResolver _sourceResolver;
110    
111    /** The context */
112    protected Context _context;
113
114    /** The analyse of files for virus helper */
115    protected FormDAO _formDAO;
116    
117    /** The form admin mail helper */
118    protected FormAdminMailsHelper _formAdminMailsHelper;
119    
120    /**
121     * The type of limitation for the mail
122     */
123    public enum LimitationMailType 
124    {
125        /** The mail for queue */
126        QUEUE,
127        /** The mail for the limit */
128        LIMIT;
129    }
130    
131    public void service(ServiceManager manager) throws ServiceException
132    {
133        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
134        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
135        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
136        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
137        _formDAO = (FormDAO) manager.lookup(FormDAO.ROLE);
138        _formAdminMailsHelper = (FormAdminMailsHelper) manager.lookup(FormAdminMailsHelper.ROLE);
139    }
140    
141    public void contextualize(Context context) throws ContextException
142    {
143        _context = context;
144    }
145    
146    /**
147     * Get the fields with email regex constraint so they can be used as receivers address for form admin mail
148     * @param formId Id of the form
149     * @return a map of the form question, key is the question id,value is the question title 
150     */
151    @Callable
152    public Map<String, Object> getAvailableAdminReceiverFields(String formId)
153    {
154        List<Object> receiverfields = new ArrayList<>();
155        
156        for (FormQuestion question : getQuestionWithMail(formId))
157        {
158            Map<String, String> properties = new HashMap<>();
159            properties.put("id", question.getNameForForm());
160            properties.put("title", question.getTitle());
161            receiverfields.add(properties);
162        }
163        
164        return Map.of("data", receiverfields);
165    }
166    
167    /**
168     * Get the fields with email regex constraint so they can be used as receivers address for form receipt mail
169     * @param formId Id of the form
170     * @return a map of the form question, key is the question id,value is the question title 
171     */
172    @SuppressWarnings("unchecked")
173    @Callable
174    public Map<String, Object> getAvailableReceiverFields(String formId)
175    {
176        Map<String, Object> results = getAvailableAdminReceiverFields(formId);
177        List<Object> receiverfields = (List<Object>) results.get("data");
178        
179        Map<String, String> properties = new HashMap<>();
180        properties.put("id", RECEIVER_COMBOBOX_ENTRY_USER_VALUE);
181        properties.put("title", _i18nUtils.translate(new I18nizableText("plugin.forms", "PLUGINS_FORMS_ACKNOWLEDGEMENT_RECEIPT_ENTRY_USER")));
182        receiverfields.add(properties);
183        
184        return Map.of("data", receiverfields);
185    }
186    
187    /**
188     * Get the list of questions which return a mail
189     * @param formId the form id
190     * @return the list of questions
191     */
192    public List<FormQuestion> getQuestionWithMail(String formId)
193    {
194        List<FormQuestion> questions = new ArrayList<>();
195        Form form = _resolver.resolveById(formId);
196        
197        for (FormQuestion question : form.getQuestions())
198        {
199            FormQuestionType type = question.getType();
200            if (type instanceof SimpleTextQuestionType)
201            {
202                if (question.hasValue(SimpleTextQuestionType.ATTRIBUTE_REGEXP) && question.getValue(SimpleTextQuestionType.ATTRIBUTE_REGEXP).equals(SimpleTextQuestionType.EMAIL_REGEX_VALUE))
203                {
204                    questions.add(question);
205                }
206            }
207            else if (type instanceof ChoicesListQuestionType cLType && (cLType.getSourceType(question) instanceof UsersSourceType || cLType.getSourceType(question) instanceof ManualWithEmailSourceType))
208            {
209                questions.add(question);
210            }
211        }
212        
213        return questions;
214    }
215    
216    /**
217     * Get all the email addresses from manual entry and/or a form field
218     * @param form the form
219     * @param entry the last entry
220     * @return an array of all the admin emails
221     */
222    public String[] getAdminEmails(Form form, FormEntry entry)
223    {
224        Optional<String[]> adminEmails = form.getAdminEmails();
225        Optional<String> otherAdminEmails = form.getOtherAdminEmails();
226        String[] emailsAsArray = adminEmails.isPresent() ? adminEmails.get() : ArrayUtils.EMPTY_STRING_ARRAY;
227        Optional<String> otherEmailSource = getReceiver(entry, otherAdminEmails);
228        String[] mergedEmails = emailsAsArray;
229        if (otherEmailSource.isPresent())
230        {
231            String otherEmails = otherEmailSource.get();
232            String[] emails = otherEmails.split("[ ,;\r]");
233            List<String> validEmails = _formAdminMailsHelper.getValidAdminEmails(emails);
234            if (!validEmails.isEmpty())
235            {
236                mergedEmails = ArrayUtils.addAll(emailsAsArray, validEmails.toArray(new String[validEmails.size()]));
237            }
238            else
239            {
240                getLogger().error("Mails addresses " + otherEmails + " did not match regex");
241            }
242        }
243        return mergedEmails;
244    }
245    
246    /**
247     * Get the receiver from the entry
248     * @param entry the entry
249     * @param receiverPath the path to get receiver
250     * @return the receiver from the entry
251     */
252    public Optional<String> getReceiver(FormEntry entry, Optional<String> receiverPath)
253    {
254        if (receiverPath.isEmpty())
255        {
256            return Optional.empty();
257        }
258        
259        if (receiverPath.get().equals(RECEIVER_COMBOBOX_ENTRY_USER_VALUE))
260        {
261            UserIdentity userIdentity = entry.getUser();
262            User user = _userManager.getUser(userIdentity);
263            return Optional.ofNullable(user != null ? user.getEmail() : null);
264        }
265        else
266        {
267            FormQuestion question = entry.getForm().getQuestion(receiverPath.get());
268            FormQuestionType type = question.getType();
269            if (type instanceof SimpleTextQuestionType)
270            {
271                return Optional.ofNullable(entry.getValue(receiverPath.get()));
272            }
273            else if (type instanceof ChoicesListQuestionType cLType)
274            {
275                if (cLType.getSourceType(question) instanceof UsersSourceType && !entry.isMultiple(receiverPath.get()))
276                {
277                    UserIdentity userIdentity = entry.getValue(receiverPath.get());
278                    if (userIdentity != null)
279                    {
280                        User user = _userManager.getUser(userIdentity);
281                        return user != null ? Optional.ofNullable(user.getEmail()) : Optional.empty();
282                    }
283                }
284                else if (cLType.getSourceType(question) instanceof ManualWithEmailSourceType manualWithEmail)
285                {
286                    String value = entry.getValue(receiverPath.get());
287                    Map<String, Object> enumParam = new HashMap<>();
288                    enumParam.put(AbstractSourceType.QUESTION_PARAM_KEY, question);
289                    try
290                    {
291                        return Optional.ofNullable(manualWithEmail.getEntryEmail(value, enumParam));
292                    }
293                    catch (Exception e)
294                    {
295                        getLogger().error("Could not get email from question '" + question.getNameForForm() + "' with value '" + value + "'");
296                    }
297                }
298            }
299            
300            return Optional.empty();
301        }
302    }
303    
304    /**
305     * Send the notification emails.
306     * @param form the form.
307     * @param entry the user input.
308     * @param adminEmails list of email address where to send the notification
309     */
310    public void sendEmailsForAdmin(Form form, FormEntry entry, String[] adminEmails)
311    {
312        try
313        {
314            String lang = _formDAO.getFormLocale(form);
315            String sender = Config.getInstance().getValue("smtp.mail.from");
316            UserIdentity userIdentity = entry.getUser();
317            User user = userIdentity != null ? _userManager.getUser(userIdentity) : null;
318            
319            // Get subject
320            Optional<String> adminEmailSubject = form.getAdminEmailSubject();
321            I18nizableText subject = adminEmailSubject.isPresent() 
322                    ? _replaceVariablesAndBreaks(form, user, adminEmailSubject.get(), lang) 
323                    : null;
324            
325            // Get body with details
326            Optional<String> adminEmailBody = form.getAdminEmailBody();
327            I18nizableText message = adminEmailBody.isPresent()
328                    ? _replaceVariablesAndBreaks(form, user, adminEmailBody.get(), lang) 
329                    : null;
330
331            String entryDetails = getMail("entry.html", entry, Map.of(), false);
332            
333            String prettyHtmlBody = StandardMailBodyHelper.newHTMLBody()
334                    .withLanguage(lang)
335                    .withTitle(subject)
336                    .withMessage(message)
337                    .withDetails(new I18nizableText("plugin.forms", "PLUGINS_FORMS_MAIL_RESULTS_DETAILS_TITLE"), entryDetails, false)
338                    .build();
339
340            // Get mail as text
341            String params = "?type=results&form-name=" + form.getTitle() + "&locale=" + lang;
342            String text = getMail("results.txt" + params, entry, Map.of(), false);
343           
344            // Send mails
345            for (String email : adminEmails)
346            {
347                if (StringUtils.isNotEmpty(email))
348                {
349                    _sendMail(form, entry, _i18nUtils.translate(subject, lang), prettyHtmlBody, text, sender, email, false, false);
350                }
351            }
352        }
353        catch (IOException e)
354        {
355            getLogger().error("Error creating the notification message.", e);
356        }
357    }
358
359    private I18nizableText _replaceVariablesAndBreaks(Form form, User user, String mailText, String language)
360    {
361        String text = mailText;
362        text = StringUtils.replace(text, "{site}", form.getSite().getTitle());
363        text = StringUtils.replace(text, "{user}",  user != null ? user.getFullName() : _i18nUtils.translate(new I18nizableText("plugin.forms", "PLUGINS_FORMS_ADMIN_EMAILS_USER_ANONYMOUS"), language));
364        text = StringUtils.replace(text, "{title}",  form.getTitle());
365        text = text.replaceAll("\r?\n", "<br/>");
366        return new I18nizableText(text);
367    }
368    
369    /**
370     * Send limitation mail when the limit is reached
371     * @param entry the form entry
372     * @param adminEmails list of email address where to send the notification
373     * @param limitationType the type of limitation
374     */
375    public void sendLimitationReachedMailForAdmin(FormEntry entry, String[] adminEmails, LimitationMailType limitationType)
376    {
377        try
378        {
379            Form form = entry.getForm();
380            
381            String lang = _formDAO.getFormLocale(form);
382            String sender = Config.getInstance().getValue("smtp.mail.from");
383            
384            // Get subject
385            I18nizableText subject = limitationType == LimitationMailType.LIMIT
386                    ? new I18nizableText("plugin.forms", "PLUGINS_FORMS_MAIL_LIMIT_SUBJECT", List.of(form.getTitle(), form.getSite().getTitle()))
387                    : new I18nizableText("plugin.forms", "PLUGINS_FORMS_MAIL_QUEUE_SUBJECT", List.of(form.getTitle(), form.getSite().getTitle()));
388            
389            // Get body
390            I18nizableText message = limitationType == LimitationMailType.LIMIT
391                    ? new I18nizableText("plugin.forms", "PLUGINS_FORMS_MAIL_LIMIT_TEXT", List.of(form.getTitle()))
392                    : new I18nizableText("plugin.forms", "PLUGINS_FORMS_MAIL_QUEUE_TEXT", List.of(form.getTitle())); 
393            
394            String prettyHtmlBody = StandardMailBodyHelper.newHTMLBody()
395                    .withLanguage(lang)
396                    .withTitle(subject)
397                    .withMessage(message)
398                    .build();
399            
400            // Get mail as text
401            I18nizableText textI18n = limitationType == LimitationMailType.LIMIT
402                    ? new I18nizableText("plugin.forms", "PLUGINS_FORMS_MAIL_LIMIT_TEXT_NO_HTML", List.of(form.getTitle()))
403                    : new I18nizableText("plugin.forms", "PLUGINS_FORMS_MAIL_QUEUE_TEXT_NO_HTML", List.of(form.getTitle()));
404            String text = _i18nUtils.translate(textI18n, lang);
405            
406            for (String email : adminEmails)
407            {
408                if (StringUtils.isNotEmpty(email))
409                {
410                    _sendMail(form, entry, _i18nUtils.translate(subject, lang), prettyHtmlBody, text, sender, email, false, false);
411                }
412            }
413        }
414        catch (IOException e)
415        {
416            getLogger().error("Error creating the limit message.", e);
417        }
418    }
419    
420    /**
421     * Send the receipt email.
422     * @param form the form.
423     * @param entry the current entry
424     */
425    public void sendReceiptEmail(Form form, FormEntry entry)
426    {
427        if (form.getReceiptSender().isPresent())
428        {
429            Optional<String> receiver = getReceiver(entry, form.getReceiptReceiver());
430            if (receiver.isPresent())
431            {
432                String lang = _formDAO.getFormLocale(form);
433                
434                String sender = form.getReceiptSender().get();
435                String subject = form.getReceiptSubject().get();
436                String bodyTxt = form.getReceiptBody().get();
437                String bodyHTML = bodyTxt.replaceAll("\r?\n", "<br/>");
438                
439                try
440                {
441                    String prettyHtmlBody = StandardMailBodyHelper.newHTMLBody()
442                            .withLanguage(lang)
443                            .withTitle(subject)
444                            .withMessage(bodyHTML)
445                            .build();
446
447                    // Always check reading restriction for the receipt email
448                    _sendMail(form, entry, subject, prettyHtmlBody, bodyTxt, sender, receiver.get(), true, true);
449                }
450                catch (IOException e)
451                {
452                    getLogger().error("An error occurred sending receipt mail to '{}'", receiver.get(), e);
453                }
454            }
455        }
456    }
457    
458    /**
459     * Send mail when the form entry if out of the queue
460     * @param form the form
461     * @param entry the form entry
462     */
463    public void sendOutOfQueueMail(Form form, FormEntry entry)
464    {
465        Optional<String> receiver = getReceiver(entry, form.getQueueMailReceiver());
466        if (receiver.isPresent())
467        {
468            String lang = _formDAO.getFormLocale(form);
469            
470            String sender = form.getQueueMailSender().get();
471            String subject = form.getQueueMailSubject().get();
472            String bodyTxt = form.getQueueMailBody().get();
473            String bodyHTML = bodyTxt.replaceAll("\r?\n", "<br/>");
474            
475            try
476            {
477                String prettyHtmlBody = StandardMailBodyHelper.newHTMLBody()
478                        .withLanguage(lang)
479                        .withTitle(subject)
480                        .withMessage(bodyHTML)
481                        .build();
482
483                // Always check reading restriction for the receipt email
484                _sendMail(form, entry, subject, prettyHtmlBody, bodyTxt, sender, receiver.get(), true, true);
485            }
486            catch (IOException e)
487            {
488                getLogger().error("An error occurred sending mail to get out of the queue to '{}'", receiver.get(), e);
489            }
490        }
491    }
492    
493    /**
494     * Send invitation mails to the users
495     * @param form the form
496     * @param users the users to receive the mail
497     * @param message the invitation message
498     */
499    public void sendInvitationMails(Form form, List<User> users, String message)
500    {
501        String lang = _formDAO.getFormLocale(form);
502        String sender = Config.getInstance().getValue("smtp.mail.from");
503        
504        // Get subject
505        I18nizableText subject = new I18nizableText("plugin.forms", "PLUGINS_FORMS_SEND_INVITATIONS_BOX_SUBJECT");
506        
507        // Get body
508        Site site = form.getSite();
509        String formURI = _getFormURI(form, lang);
510        
511        String replacedMessage = StringUtils.replace(message, "{site}", site.getTitle());
512        String textMessage = StringUtils.replace(replacedMessage, "{link}", formURI);
513        String htmlMessage = StringUtils.replace(replacedMessage, "{link}", "<a href='" + formURI + "'>" + formURI + "</a>");
514        
515        for (User user : users)
516        {
517            try
518            {
519                String finalTextMessage = StringUtils.replace(textMessage, "{name}", user.getFullName());
520                String finalHtmlMessage = StringUtils.replace(htmlMessage, "{name}", user.getFullName());
521                String prettyHtmlBody = StandardMailBodyHelper.newHTMLBody()
522                        .withLanguage(lang)
523                        .withTitle(subject)
524                        .withMessage(finalHtmlMessage)
525                        .withLink(formURI, new I18nizableText("plugin.forms", "PLUGINS_FORMS_SEND_INVITATIONS_LINK_LABEL"))
526                        .build();
527                
528                _sendMail(form, null, _i18nUtils.translate(subject, lang), prettyHtmlBody, finalTextMessage, sender, user.getEmail(), false, true);
529            }
530            catch (IOException e) 
531            {
532                getLogger().error("Unable to send invitation mail to user {}", user.getEmail(), e);
533            }
534        }
535    }
536    
537    /**
538     * Get the form page URI
539     * @param form the form
540     * @param language the language
541     * @return the form page URI
542     */
543    protected String _getFormURI (Form form, String language)
544    {
545        Site site = form.getSite();
546        Optional<Page> page = _formDAO.getFormPage(form.getId(), site.getName())
547            .stream()
548            .filter(Page.class::isInstance)
549            .map(Page.class::cast)
550            .filter(p -> p.getSitemapName().equals(language))
551            .findAny();
552        
553        return page.map(p -> site.getUrl() + "/" + p.getSitemap().getName() + "/" + p.getPathInSitemap() + ".html")
554                   .orElse(null);
555    }
556    
557    /**
558     * Get a mail pipeline's content.
559     * @param resource the mail resource pipeline 
560     * @param entry the user input.
561     * @param additionalParameters the additional parameters
562     * @param withReadingRestriction <code>true</code> to enable reading restriction for form data in the mail
563     * @return the mail content.
564     * @throws IOException if an error occurs.
565     */
566    public String getMail(String resource, FormEntry entry, Map<String, Object> additionalParameters, boolean withReadingRestriction) throws IOException
567    {
568        Source src = null;
569        Request request = ContextHelper.getRequest(_context);
570        Form form = entry.getForm();
571        
572        try
573        {
574            request.setAttribute(IGNORE_RIGHT_KEY, true);
575            
576            String uri = "cocoon:/mail/entry/" + resource;
577            Map<String, Object> parameters = new HashMap<>();
578            parameters.put("formId", form.getId());
579            parameters.put("entryId", entry.getId());
580            parameters.put(READ_RESTRICTION_ENABLE, withReadingRestriction);
581
582            parameters.putAll(additionalParameters);
583            
584            src = _sourceResolver.resolveURI(uri, null, parameters);
585            Reader reader = new InputStreamReader(src.getInputStream(), "UTF-8");
586            return IOUtils.toString(reader);
587        }
588        finally
589        {
590            request.setAttribute(IGNORE_RIGHT_KEY, false);
591            _sourceResolver.release(src);
592        }
593    }
594    
595    /**
596     * Get the files of a user input.
597     * @param form The current form
598     * @param entry The current entry
599     * @return the files submitted by the user.
600     */
601    protected Collection<NamedStream> _getFiles(Form form, FormEntry entry)
602    {
603        return form.getQuestions()
604            .stream()
605            .filter(q -> q.getType() instanceof FileQuestionType)
606            .map(q -> entry.<File>getValue(q.getNameForForm()))
607            .filter(Objects::nonNull)
608            .map(f -> new NamedStream(f.getInputStream(), f.getName(), f.getMimeType()))
609            .collect(Collectors.toList());
610    }
611    
612    private void _sendMail(Form form, FormEntry entry, String subject, String html, String text, String sender, String recipient, boolean addFormInformation, boolean withReadingRestriction)
613    {
614        try
615        {
616            String htmlBody = html;
617            String textBody = text;
618        
619            Collection<NamedStream> records = entry != null ? _getFiles(form, entry) : new ArrayList<>();
620            try
621            {
622                if (addFormInformation)
623                {
624                    if (textBody.contains(_FORM_ENTRY_PATTERN))
625                    {
626                        String entry2text = getMail("entry.txt", entry, Map.of(), withReadingRestriction);
627                        textBody = StringUtils.replace(textBody, _FORM_ENTRY_PATTERN, entry2text);
628                    }
629                    
630                    if (htmlBody.contains(_FORM_ENTRY_PATTERN))
631                    {
632                        String entry2html = getMail("entry.html", entry, Map.of(), withReadingRestriction);
633                        htmlBody = StringUtils.replace(htmlBody, _FORM_ENTRY_PATTERN, entry2html);
634                    }
635                }
636                
637                MailBuilder mailBuilder = SendMailHelper.newMail()
638                    .withAsync(true)
639                    .withSubject(subject)
640                    .withHTMLBody(htmlBody)
641                    .withTextBody(textBody)
642                    .withSender(sender)
643                    .withRecipient(recipient);
644                
645                if (!records.isEmpty())
646                {
647                    mailBuilder = mailBuilder.withAttachmentsAsStream(records);
648                }
649                mailBuilder.sendMail();
650            }
651            finally 
652            {
653                for (NamedStream record : records)
654                {
655                    IOUtils.close(record.inputStream());
656                }
657            }
658        }
659        catch (MessagingException | IOException e)
660        {
661            getLogger().error("Error sending the mail to " + recipient, e);
662        }
663    }
664}