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);
}
}