MailService.java

/*
 * Copyright © 2023-2025 The CTAN Team and individual authors
 *
 * This file is distributed under the 3-clause BSD license.
 * See file LICENSE for details.
 */
package org.ctan.site.services.mail;

import static org.ctan.site.services.util.NullCheck.isNotNull;
import static org.ctan.site.services.util.NullCheck.isNotNullObject;

import java.io.IOException;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.util.Map;
import java.util.Properties;

import org.ctan.site.CtanConfiguration.CtanConfig;
import org.ctan.site.CtanConfiguration.MailConfig;
import org.ctan.site.CtanConfiguration.MailType;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.Transport;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Builder.Default;
import lombok.Data;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;

/**
 * The class <code>MailService</code> contains a service to send mails.
 *
 * <p>
 * A mail template can be used. This is processed with
 * <a href="https://freemarker.apache.org/">Apache FreeMarker™</a> to apply
 * substitutions.
 * </p>
 *
 * @author <a href="mailto:gene@ctan.org">Gerd Neugebauer</a>
 */
@Slf4j
public class MailService {

    /**
     * The mail transport object.
     */
    @Data
    @Builder
    @AllArgsConstructor
    @SuppressFBWarnings(value = "EI_EXPOSE_REP")
    public static class Mail {

        /**
         * The field <code>type</code> contains the type in the configuration to
         * use. If type is set then `from` and `template` are taken from there.
         */
        private String type;

        /**
         * The field <code>from</code> contains the from address of the mail.
         */
        private String from;

        /**
         * The field <code>subject</code> contains the subject of the mail. If
         * the template is used then this value is overwritten from the
         * template.
         */
        private String subject;

        /**
         * The field <code>locale</code> contains the desired language. if null
         * then the default locale (en) is used.
         */
        private String locale;

        /**
         * The field <code>model</code> contains the arguments to be inserted in
         * the template.
         */
        private Map<String, Object> model;

        /**
         * The field <code>html</code> contains the indicator for HTML emails.
         * If {@code false} the text emails are sent.
         */
        private boolean html;

        /**
         * The field <code>text</code> contains the text body of the mail. If
         * the template is used then this value is overwritten from the
         * template.
         */
        private String text;

        /**
         * The field <code>template</code> contains the base name of the
         * template.
         */
        @Default
        private String template = null;

        /**
         * The field <code>to</code> contains the to address of the mail.
         */
        private String to;
    }

    /**
     * The field <code>cfg</code> contains the mail configuration.
     */
    private @NonNull MailConfig cfg;

    /**
     * This is the constructor for the class <code>MailService</code>.
     *
     * @param cfg the configuration
     */
    @SuppressFBWarnings(value = "CT_CONSTRUCTOR_THROW")
    public MailService(@NonNull MailConfig cfg) {

        this.cfg = cfg;
        var smtp = cfg.getSmtp();
        isNotNullObject(smtp, "mail.smtp");
        isNotNull(smtp.getHost(), "mail.smtp.host");
        isNotNull(cfg.getFrom(), "mail.from");
    }

    /**
     * The method <code>expandTemplate</code> provides means to evaluate a mail
     * template.
     *
     * @param mail the mail to be sent
     * @throws MailException in case of an error
     */
    private void expandTemplate(Mail mail)
        throws MailException {

        var cfg = new Configuration(Configuration.VERSION_2_3_32);
        cfg.setClassForTemplateLoading(MailService.class, "/email/");
        Template template;
        var locale = mail.getLocale();
        if (locale == null) {
            locale = CtanConfig.LOCALES.get(0);
        }
        var templateName = mail.getTemplate() + "_" + locale
            + "." + (mail.isHtml() ? "html" : "txt");
        String expanded;
        try (var out = new StringWriter()) {
            template = cfg.getTemplate(templateName);
            template.process(mail.getModel(), out);
            expanded = out.getBuffer().toString();
        } catch (TemplateException | IOException e) {
            log.error(e.getMessage());
            throw new MailException(e.getMessage());
        }
        var i = expanded.indexOf('\n');
        if (i <= 9 || !expanded.startsWith("subject: ")) {
            throw new MailException(
                "Missing subject in `" + templateName + "´");
        }
        mail.subject = expanded.substring(8, i).replaceAll("^ *", "");
        mail.text = expanded.substring(i + 1).replaceAll("^[ \r\n]+", "");
    }

    /**
     * The method <code>send</code> provides means to sent an email.
     *
     * @param mail the data for the mail to be sent
     * @return the message sent
     * @throws MailException in case of an error
     */
    public MimeMessage send(@NonNull Mail mail) throws MailException {

        mail.setFrom(cfg.getFrom());
        var type = mail.getType();
        if (type != null) {
            MailType params = cfg.getList(type);
            isNotNullObject(params, "mail.list." + type);
            String template = isNotNull(params.getTemplate(),
                "mail.list." + type + ".template");
            mail.setTemplate(template);
            String to = isNotNull(params.getTo(),
                "mail.list." + type + ".to");
            mail.setTo(to);
        }
        isNotNull(mail.getTo(), "to");
        if (mail.getTemplate() == null) {
            isNotNull(mail.getText(), "text or template");
            isNotNull(mail.getSubject(), "subject");
        } else {
            expandTemplate(mail);
        }
        var properties = new Properties();
        properties.setProperty("mail.smtp.host", cfg.getSmtp().getHost());
        var port = cfg.getSmtp().getPort();
        properties.setProperty("mail.smtp.port", port == null ? "587" : port);
        // properties.setProperty("mail.smtp.auth", "true");
        // properties.setProperty("mail.smtp.starttls.enable", "true");
        try {
            var message =
                new MimeMessage(
                    Session.getDefaultInstance(properties));
            message.setFrom(toAddress(mail.getFrom(), cfg.getFrom()));
            message.addRecipient(Message.RecipientType.TO,
                new InternetAddress(mail.getTo()));
            message.setSubject(mail.getSubject(), "UTF-8");
            if (mail.isHtml()) {
                message.setContent(mail.getText(), "text/html; charset=utf-8");
                message.setHeader("content-type", "text/html; charset=utf-8");
            } else {
                message.setText(mail.getText(), "UTF-8");
            }
            Transport.send(message);
            return message;
        } catch (MessagingException | UnsupportedEncodingException e) {
            throw new MailException(e);
        }
    }

    /**
     * The method <code>toAddress</code> provides means to create an Internet
     * address.
     *
     * @param address the address
     * @param fallback the fallback value
     * @return the Internet address
     * @throws AddressException in case of an error
     * @throws UnsupportedEncodingException in case of an error
     */
    private InternetAddress toAddress(String address, String fallback)
        throws AddressException,
            UnsupportedEncodingException {

        if (address == null) {
            address = fallback;
        }
        if (address == null) {
            throw new AddressException();
        }
        int i = address.indexOf(' ');
        if (i < 0) {
            return new InternetAddress(address);
        }
        String name = address.substring(i + 1);
        address = address.substring(0, i);
        return new InternetAddress(address, name);
    }
}