
com.databasesandlife.util.EmailTemplate Maven / Gradle / Ivy
package com.databasesandlife.util;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import jakarta.activation.DataHandler;
import jakarta.activation.DataSource;
import jakarta.activation.MimetypesFileTypeMap;
import jakarta.mail.BodyPart;
import jakarta.mail.Message.RecipientType;
import jakarta.mail.MessagingException;
import jakarta.mail.Part;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMultipart;
import org.apache.commons.io.FileUtils;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;
import org.apache.velocity.runtime.RuntimeConstants;
import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader;
import org.apache.velocity.tools.generic.EscapeTool;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.regex.Pattern;
import static java.util.regex.Matcher.quoteReplacement;
/**
Represents a directory in the classpath, which contains text and potentially graphics, in multiple languages and multiple formats, for outgoing notification emails.
A directory within the classpath should be created, and filled with the following files:
- body.velocity.utf8.txt - UTF-8 formatted body of the text/plain part of the email to be sent; Velocity template
- body.velocity.utf8.html - UTF-8 formatted HTML version of the email to be sent; Velocity template
- subject.velocity.utf8.txt - UTF-8 formatted subject of the email to be sent; Velocity template
- from.velocity.utf8.txt - An file containing an email address such as "John Smith <[email protected]>"; Velocity template
- xyz.jpg - Any resources required from the HTML version of the emails. They are referenced simply as <img src="xyz.jpg"> from the HTML versions, so the HTML version can be easily tested locally in a browser. This is replaced by <img src="cid:xyz.jpg"> by the software, as this is what is required in the email.
- Optionally MyEmailTemplate.java - Subclass of EmailTemplate, means that the whole template directory can be referenced via static typing, can be renamed with refactoring tools, and so on.
Concerning languages, although e.g. "subject.utf8.txt" must be present, there may also be files with names such as "subject_de.utf8.txt" files for other Locales.
One or both of the plain-text and HTML versions of the email must be present. If they are both present then a "multipart/alternative" email is sent.
The templates are Velocity templates meaning that variables like ${XYZ}
can be used.
Velocity supports #foreach
etc.
For variables in HTML files use $esc.html($xyz)
.
For unit testing, use the static method {@link #setLastBodyForTestingInsteadOfSendingEmails()}.
After that method has been called, no emails will be sent,
instead the method {@link #getLastBodyForTesting} may be used to retrieve the last sent plain/text email body.
This allows one to assert that particular emails would be sent, and that they contain particular text.
For testing with e.g. Litmus (tools to test your emails across the many email clients),
the facility {@link #writeHtmlPartToFile(Locale, URL, Map, File)}
exists.
Emails can be written to disk as opposed to sent, in order that the real email that would be sent can be uploaded to
Litmus for testing, without any velocity template commands, and with real user data.
Writing code such as new EmailTemplate("myproject.mtpl.registration")
has the disadvantage that if that package is renamed, refactoring tools will not see this string, and not rename it. Errors will result at run-time. The solution is to create a class in the directory, which calls its superclass constructor with its package name. This class is then instanciated in the client code, instead of the general EmailTemplate
.
You can send attachments with your email (e.g. PDF invoices) by passing multiple {@link Attachment} objects to the send method.
Either you implement your own attachment, providing the filename, mime type and a way to get an InputStream for the bytes of the attachment,
or you can just create a {@link ByteArrayAttachment} by passing the filename, mime type and a byte[]
.
Concerning naming,
- "Email" is used over Mail, in order to be consistent with the term "email address", which is never called "mail address".
- The word "template" is always used, as an "email" is a particular email which has been sent, whereas an object of this class represents the possibility to create such emails, i.e. is a template for such emails.
- "Email address" is used in preference to "email", as an "email" is the message which is sent, and it is necessary to differentiate between the address to which a message is sent, and the message itself.
- "utf8" is used in the filenames to make absolutely clear to all concerned what encoding should be used for the contents of those files.
Usage
In the directory containing the template files:
class RegistrationEmailTemplate extends EmailTemplate {
public RegistrationEmailTemplate() {
super(RegistrationEmailTemplate.class.getPackage());
}
// other methods can be added, specific to this email template
}
In client code:
class RegistrationProcess {
public registerNewUser(InternetAddress emailAddress, Locale language, String name, ... ) {
var smtpServer = "localhost";
var tx = new EmailTransaction(smtpServer);
Map<String,String> params = new HashMap<String,String>();
params.put("USERNAME", name);
var tpl = new RegistrationEmailTemplate();
tpl.send(tx, recipientEmailAddress, language, params);
tx.commit();
}
}
In unit test code:
class RegistrationProcessTest extends TestCase {
public testRegisterNewUser() {
EmailTemplate.setLastBodyForTestingInsteadOfSendingEmails();
new RegistrationProcess().registerNewUser("[email protected]", "Adrian");
var txt = EmailTemplate.getLastBodyForTesting();
assertTrue(txt.contains("Adrian"));
}
}
* @author This source is copyright Adrian Smith and licensed under the LGPL 3.
* @see Project on GitHub
*/
public class EmailTemplate {
// --------------------------------------------------------------------------------------------------------
// Internal
// --------------------------------------------------------------------------------------------------------
/** For example "com.project.emailtpl.xyz" */
protected String packageStr;
static protected boolean setLastBodyForTestingInsteadOfSendingEmails = false;
static protected String lastBodyForTesting = "";
protected class FileAttachmentJavamailDataSource implements DataSource {
String leafNameWithoutExtension, extension;
FileAttachmentJavamailDataSource(String l, String e) { leafNameWithoutExtension=l; extension=e; }
public String getContentType() { return new MimetypesFileTypeMap().getContentType(getName()); }
public String getName() { return leafNameWithoutExtension + "." + extension; }
public InputStream getInputStream() { return getClass().getClassLoader().getResourceAsStream(findFile(getName())); }
public OutputStream getOutputStream() { throw new RuntimeException(); }
}
/** @return full classpath name to the file */
protected String findFile(String leafName)
throws FileNotFoundInEmailTemplateDirectoryException {
var packageWithSlashes = packageStr.replaceAll("\\.", "/"); // e.g. "com/myproject/mtpl/registrationemail"
var result = packageWithSlashes + "/" + leafName;
if (getClass().getClassLoader().getResource(result) == null)
throw new FileNotFoundInEmailTemplateDirectoryException(
"File '" + leafName +"' not found in email tpl package '" + packageStr + "'");
return result;
}
/**
* @param extension for example ".txt"
* @return full classpath name to the file
*/
protected String findFile(String leafNameStem, Locale locale, String extension)
throws FileNotFoundInEmailTemplateDirectoryException {
try {
return findFile(leafNameStem + "_" + locale.getLanguage() + extension);
}
catch (FileNotFoundInEmailTemplateDirectoryException e) {
return findFile(leafNameStem + extension);
}
}
/**
* Will find a file like "body.velocity.utf8.txt"
* @param leafNameStem for example "body"
* @param extension for example ".txt"
*/
protected String expandVelocityTemplate(String leafNameStem, Locale locale, String extension, Map parameters)
throws FileNotFoundInEmailTemplateDirectoryException {
var velocity = new VelocityEngine();
velocity.setProperty(RuntimeConstants.RESOURCE_LOADER, "classpath");
velocity.setProperty("classpath.resource.loader.class", ClasspathResourceLoader.class.getName());
var template = velocity.getTemplate(findFile(leafNameStem, locale, ".velocity.utf8" + extension), StandardCharsets.UTF_8.name());
var ctx = new VelocityContext();
for (var p : parameters.entrySet()) ctx.put(p.getKey(), p.getValue());
ctx.put("esc", new EscapeTool());
var result = new StringWriter();
template.merge(ctx, result);
return result.toString();
}
@SuppressFBWarnings("ST_WRITE_TO_STATIC_FROM_INSTANCE_METHOD")
protected BodyPart parsePlainTextBodyPart(Locale locale, Map parameters)
throws FileNotFoundInEmailTemplateDirectoryException, MessagingException {
var textContents = expandVelocityTemplate("body", locale, ".txt", parameters);
lastBodyForTesting = textContents;
var result = new MimeBodyPart();
result.setText(textContents);
return result;
}
protected BodyPart parseHtmlBodyPart(Locale locale, Map parameters)
throws FileNotFoundInEmailTemplateDirectoryException, MessagingException {
var htmlContents = expandVelocityTemplate("body", locale, ".html", parameters);
Map referencedFiles = new TreeMap<>();
var htmlContentsWithCid = new StringBuilder();
var fileMatcher = Pattern.compile("(['\"])([\\w\\-]+)\\.(\\w{3,4})['\"]").matcher(htmlContents);
while (fileMatcher.find()) {
var quote = fileMatcher.group(1);
var leafNameWithoutExtension = fileMatcher.group(2);
var extension = fileMatcher.group(3);
var leafNameWithExtension = leafNameWithoutExtension + "." + extension;
var filePart = new MimeBodyPart();
var source = new FileAttachmentJavamailDataSource(leafNameWithoutExtension, extension);
filePart.setDataHandler(new DataHandler(source));
filePart.setFileName(leafNameWithExtension);
filePart.setHeader("Content-ID", "<"+leafNameWithExtension+">");
filePart.setDisposition(Part.INLINE);
referencedFiles.put(leafNameWithExtension, filePart);
fileMatcher.appendReplacement(htmlContentsWithCid, quoteReplacement(quote + "cid:" + leafNameWithExtension + quote));
}
fileMatcher.appendTail(htmlContentsWithCid);
var htmlContentsPart = new MimeBodyPart();
htmlContentsPart.setContent(htmlContentsWithCid.toString(), "text/html; charset=UTF-8");
var htmlMultiPart = new MimeMultipart("related");
htmlMultiPart.addBodyPart(htmlContentsPart);
for (var attachmentPart : referencedFiles.values())
htmlMultiPart.addBodyPart(attachmentPart);
var result = new MimeBodyPart();
result.setContent(htmlMultiPart);
return result;
}
public static BodyPart newAttachmentBodyPart(final Attachment attachment) throws MessagingException {
var dataSource = new DataSource() {
@Override public String getContentType() { return attachment.getMimeType(); }
@Override public InputStream getInputStream() { return attachment.newInputStream(); }
@Override public String getName() { return attachment.getLeafNameInclExtension(); }
@Override public OutputStream getOutputStream() { throw new RuntimeException("unreachable"); }
};
var filePart = new MimeBodyPart();
filePart.setDataHandler(new DataHandler(dataSource));
filePart.setFileName(attachment.getLeafNameInclExtension());
filePart.setDisposition(Part.ATTACHMENT);
return filePart;
}
// --------------------------------------------------------------------------------------------------------
// Public methods / API
// --------------------------------------------------------------------------------------------------------
public static class FileNotFoundInEmailTemplateDirectoryException extends RuntimeException {
public FileNotFoundInEmailTemplateDirectoryException(String msg) { super(msg); }
}
public interface Attachment {
/** For example "invoice.pdf" */ public String getLeafNameInclExtension();
/** For example "application/pdf" */ public String getMimeType();
/** To provide the bytes of the file */ public InputStream newInputStream();
}
public static class ByteArrayAttachment implements Attachment {
protected String leafNameInclExtension, mimeType;
protected byte[] bytes;
public ByteArrayAttachment(String leafNameInclExtension, String mimeType, byte[] bytes) {
this.leafNameInclExtension = leafNameInclExtension; this.mimeType = mimeType; this.bytes = bytes; }
@Override public String getLeafNameInclExtension() { return leafNameInclExtension; }
@Override public String getMimeType() { return mimeType; }
@Override public InputStream newInputStream() { return new ByteArrayInputStream(bytes); }
}
public EmailTemplate(Package pkg) { this.packageStr = pkg.getName(); }
public EmailTemplate(String pkgStr) { this.packageStr = pkgStr; }
/** Henceforth, no emails will be sent; instead the body will be recorded for inspection by {@link #getLastBodyForTesting()}. */
static public void setLastBodyForTestingInsteadOfSendingEmails() { setLastBodyForTestingInsteadOfSendingEmails = true; }
/** Return the plain text body of the last email which has been sent; or the empty string in case no emails have been sent. */
static public String getLastBodyForTesting() { return lastBodyForTesting; }
/** Send an email based on this email template. */
public void send(
EmailTransaction tx, Collection recipientEmailAddresses, Locale locale,
Map parameters, Attachment... attachments
) {
try {
// Create the "message text" part which is the multipart/alternative of the text/plain and HTML versions
var messageText = new MimeMultipart("alternative");
try { messageText.addBodyPart(parsePlainTextBodyPart(locale, parameters)); }
catch (FileNotFoundInEmailTemplateDirectoryException ignored) { }
try { messageText.addBodyPart(parseHtmlBodyPart(locale, parameters)); }
catch (FileNotFoundInEmailTemplateDirectoryException ignored) { }
if (messageText.getCount() < 1) throw new RuntimeException("No html nor text body found for email template: " + packageStr);
if (setLastBodyForTestingInsteadOfSendingEmails) return;
// Create the "main part" which is multipart/mixed of the message body and attachments
var messageTextPart = new MimeBodyPart();
messageTextPart.setContent(messageText);
var mainPart = new MimeMultipart("mixed");
mainPart.addBodyPart(messageTextPart);
for (var a : attachments) mainPart.addBodyPart(newAttachmentBodyPart(a));
// Create the message from the subject and body
var msg = tx.newMimeMessage();
msg.setFrom(new InternetAddress(expandVelocityTemplate("from", locale, ".txt", parameters)));
msg.addRecipients(RecipientType.TO, recipientEmailAddresses.toArray(new InternetAddress[0]));
msg.setSubject(expandVelocityTemplate("subject", locale, ".txt", parameters));
msg.setContent(mainPart);
msg.setSentDate(new Date());
// Send the message
tx.send(msg);
}
catch (MessagingException e) { throw new RuntimeException(e); }
}
public void send(
EmailTransaction tx, InternetAddress recipientEmailAddress, Locale locale,
Map parameters, Attachment... attachments
) {
send(tx, List.of(recipientEmailAddress), locale, parameters, attachments);
}
protected String replaceImagesWithBaseURL(URL imageBaseUrl, String htmlContents) {
var result = new StringBuffer();
var fileMatcher = Pattern.compile("(['\"])([\\w\\-]+)\\.(\\w{3,4})['\"]").matcher(htmlContents);
while (fileMatcher.find()) {
var quote = fileMatcher.group(1);
var leafNameWithoutExtension = fileMatcher.group(2);
var extension = fileMatcher.group(3);
var leafNameWithExtension = leafNameWithoutExtension + "." + extension;
fileMatcher.appendReplacement(result, quoteReplacement(quote + imageBaseUrl + leafNameWithExtension + quote));
}
fileMatcher.appendTail(result);
return result.toString();
}
/**
* @param imageBaseUrl images in the directory must be uploaded somewhere. They are then replaced
* in the HTML with a link to that place.
* @param file where to write the HTML to
*/
public void writeHtmlPartToFile(Locale locale, URL imageBaseUrl, Map parameters, File file) {
try {
var body = expandVelocityTemplate("body", locale, ".html", parameters);
body = replaceImagesWithBaseURL(imageBaseUrl, body);
FileUtils.writeStringToFile(file, body, StandardCharsets.UTF_8.name());
LoggerFactory.getLogger(getClass()).info("Successfully wrote email template to '"+file+"'");
}
catch (IOException e) { throw new RuntimeException(e); }
}
}