All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.uhndata.cards.emailnotifications.EmailTemplate Maven / Gradle / Ivy

There is a newer version: 0.9.27
Show newest version
/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package io.uhndata.cards.emailnotifications;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.Property;
import javax.jcr.PropertyIterator;
import javax.jcr.PropertyType;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.Value;

import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.ImmutableTriple;
import org.apache.commons.lang3.tuple.Triple;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;

import io.uhndata.cards.forms.api.FormUtils;

/**
 * A read-only email template ready to be customized into an {@link Email}. An email template already defines the
 * sender, subject, HTML body template, text body template, reference properties to interpolate into the body templates,
 * and extra attachments to include. To create a template, use {@link #builder()} to obtain a new {@link Builder
 * template builder}, invoke the builder's method to set the required values, then {@link Builder#build() build} the
 * template. To instantiate a template into an actual email for a specific subject ready to be sent, use
 * {@link #getEmailBuilderForSubject}. This already looks up all the answers for the questions referenced in
 * {@link #getExtraProperties()} and interpolates the body templates.
 * 

* An alternative way to configure an email template is through {@code cards:EmailTemplate} nodes. Each such node * defines all the required properties like the Sender and {@link #getSubject() Subject}, the {@link #getHtmlTemplate() * HTML} and {@link #getTextTemplate() text templates}, can hold other properties to be used as * {@link #getExtraProperties() extra properties}, and any other children will be used as {@link #getInlineAttachments() * attachments}. To build a template starting from such a Node, use {@link #builder(Node, ResourceResolver)}. *

* * @see Email * @version $Id: 69ed4cda81f05bf9a191cd556151efce7f8bec89 $ */ public class EmailTemplate { /** JCR nodetype for nodes holding email templates. */ public static final String NODETYPE = "cards:EmailTemplate"; /** JCR nodetype for nodes holding extra attachments to include in the email. */ public static final String OTHER_ATTACHMENTS_NODETYPE = "nt:file"; /** Property of template nodes holding the sender email address. */ public static final String SENDER_ADDRESS_PROPERTY = "senderAddress"; /** Property of template nodes holding the sender display name. */ public static final String SENDER_NAME_PROPERTY = "senderName"; /** Property of template nodes holding the (optional) reply-to email address. */ public static final String REPLY_TO_ADDRESS_PROPERTY = "replyToAddress"; /** Property of template nodes holding the (optional) reply-to display name. */ public static final String REPLY_TO_NAME_PROPERTY = "replyToName"; /** Property of template nodes holding the subject line. */ public static final String SUBJECT_PROPERTY = "subject"; /** Name of the child node holding the HTML body template. */ public static final String HTML_TEMPLATE_NODE = "bodyTemplate.html"; /** Name of the child node holding the fallback plain text template. */ public static final String TEXT_TEMPLATE_NODE = "bodyTemplate.txt"; /** The node where elements common to all email templates can be placed. */ public static final String COMMON_TEMPLATE_NODE = "/apps/cards/mailTemplates/"; /** The node where inline attachments common to all email templates can be placed. */ public static final String COMMON_ATTACHMENTS_NODE = COMMON_TEMPLATE_NODE + "commonAttachments"; /** * An optional child node of the email template, holding a prefix for the HTML body template. If present, this will * override the common HTML header and will be added before the HTML body template of each email template. */ public static final String HTML_BODY_HEADER = "bodyTemplate.header.html"; /** * An optional node holding a common prefix for the HTML body template. If present, this will be added before the * HTML body template of each email template. */ public static final String COMMON_HTML_BODY_HEADER = COMMON_TEMPLATE_NODE + HTML_BODY_HEADER; /** * An optional child node of the email template, holding a suffix for the HTML body template. If present, this will * override the common HTML footer and will be added after the HTML body template of each email template. */ public static final String HTML_BODY_FOOTER = "bodyTemplate.footer.html"; /** * An optional node holding a common suffix for the HTML body template. If present, this will be added after the * HTML body template of each email template. */ public static final String COMMON_HTML_BODY_FOOTER = COMMON_TEMPLATE_NODE + HTML_BODY_FOOTER; /** * An optional child node of the email template, holding a prefix for the plain text body template. If present, this * will override the common plain text header and will be added before the body template of each email template. */ public static final String TEXT_BODY_HEADER = "bodyTemplate.header.txt"; /** * An optional node holding a common prefix for the plain text body template. If present, this will be added before * the text body template of each email template. */ public static final String COMMON_TEXT_BODY_HEADER = COMMON_TEMPLATE_NODE + TEXT_BODY_HEADER; /** * An optional child node of the email template, holding a suffix for the plain text body template. If present, this * will override the common plain text footer and will be added after the body template of each email template. */ public static final String TEXT_BODY_FOOTER = "bodyTemplate.footer.txt"; /** * An optional node holding a common suffix for the plain text body template. If present, this will be added after * the text body template of each email template. */ public static final String COMMON_TEXT_BODY_FOOTER = COMMON_TEMPLATE_NODE + TEXT_BODY_FOOTER; private String senderAddress; private String senderName; private String replyToAddress; private String replyToName; private String subject; private String htmlTemplate; private String textTemplate; private final Map properties = new HashMap<>(); private final List> inlineAttachments = new LinkedList<>(); protected EmailTemplate() { // Nothing to do, this just makes the class uninstantiable } protected EmailTemplate(final EmailTemplate other) { this.senderAddress = other.senderAddress; this.senderName = other.senderName; this.replyToAddress = other.replyToAddress; this.replyToName = other.replyToName; this.subject = other.subject; this.htmlTemplate = other.htmlTemplate; this.textTemplate = other.textTemplate; this.inlineAttachments.addAll(other.inlineAttachments); this.properties.putAll(other.properties); } /** * The email address the email is sent from, part of the {@code From} email header together with * {@link #getSenderName()}. * * @return an email address */ public String getSenderAddress() { return this.senderAddress; } /** * The name displayed as the sender, part of the {@code From} email header together with * {@link #getSenderAddress()}. * * @return a name */ public String getSenderName() { return this.senderName; } /** * The email address to send replies to, part of the {@code Reply-To} email header together with * {@link #getReplyToName()}. * * @return an email address */ public String getReplyToAddress() { return StringUtils.defaultIfBlank(this.replyToAddress, getSenderAddress()); } /** * The name displayed as the reply-to destination, part of the {@code Reply-To} email header together with * {@link #getReplyToAddress()}. * * @return a name */ public String getReplyToName() { return StringUtils.defaultIfBlank(this.replyToName, getSenderName()); } /** * The title of the email, the {@code Subject} email header. * * @return a subject */ public String getSubject() { // TODO Add support for variable interpolation in the subject return this.subject; } /** * A set of extra properties to include as variables available for substitution in the email body templates. The key * of the map is the variable name exposed in the template. The value can be either a path to a JCR question node, a * path to a JCR property, or a simple value. when instantiating the template into an actual email for a specific * subject: *
    *
  • If it's a question reference, an answer to it for the subject will be looked up, and the first such answer, * if any, will be used in the template. *
  • If it's a JCR property, the value of the property will be used. *
  • If it's a simple value, not a JCR reference, the value is used as-is.
  • *
* * @return a map from a variable name to a path to a question node */ public Map getExtraProperties() { return new HashMap<>(this.properties); } /** * A set of extra attachments to include inline in the email. Usually, these are images displayed inline with * {@code src="cid:imageName.png"} in the HTML body. Each attachments has three parts: the name of the attachment, * which is how the attachment can be referenced with the {@code cid:} syntax; the MIME type of the attachment; and * the actual content as a byte array. * * @return a list of triples [name, MIME type, content] */ public List> getInlineAttachments() { return new LinkedList<>(this.inlineAttachments); } /** * The template for the HTML part of the email body. The template can contain placeholders for variables to be * filled in for a specific email instance, using the ${variable} syntax. Such variables can be set in * {@link #getExtraProperties()} or passed in {@link #getEmailBuilderForSubject(Node, Map, FormUtils)}. * * @return a large string, may be {@code null} if no HTML body is to be used */ public String getHtmlTemplate() { return this.htmlTemplate; } /** * The template for the plain text part of the email body. The template can contain placeholders for variables to be * filled in for a specific email instance, using the ${variable} syntax. Such variables can be set in * {@link #getExtraProperties()} or passed in {@link #getEmailBuilderForSubject(Node, Map, FormUtils)}. * * @return a large string, may be {@code null} if no plain text body is to be used */ public String getTextTemplate() { return this.textTemplate; } /** * Start instantiating the template into an actual email by obtaining an email builder based on this template. No * extra processing of the template is done, the body templates must be manually * {@link EmailUtils#renderEmailTemplate(String, Map) interpolated} and * {@link Email.Builder#withBody(String, String) passed into the email builder}. * * @return an email builder based on this template */ public Email.Builder getEmailBuilder() { return new Email.Builder(this); } /** * Start instantiating the template into an actual email for a specific subject by obtaining an email builder based * on this template, with the body templates already interpolated with answers to the referenced questions for the * targeted subject and the additional properties passed to this method. A specific recipient name and email address * {@link Email.Builder#withRecipient(String, String) must be set} before the email can be built. * * @param subject a subject node whose answers will be used in the body template * @param extraProperties additional properties to interpolate in the body template, a map from variable name to * actual value * @param formUtils utilities for working with forms * @return an email builder based on this template, interpolated for the target subject */ public Email.Builder getEmailBuilderForSubject(Node subject, Map extraProperties, FormUtils formUtils) { final Email.Builder builder = getEmailBuilder(); final Map actualProperties = getProperties(subject, extraProperties, formUtils); builder.withBody( EmailUtils.renderEmailTemplate(this.htmlTemplate, actualProperties), EmailUtils.renderEmailTemplate(this.textTemplate, actualProperties)); return builder; } /** * Start building a template from scratch. * * @return a new template builder */ public static final Builder builder() { return new Builder(); } /** * Start building a template based on the stored template node. If the template node is fully configured, the * template can be directly {@link Builder#build() built} after this, but, if needed, other properties or * attachments may be manually added before finalizing the template. * * @param template a JCR node of type {@code cards:EmailTemplate} * @param resolver a resource resolver, needed for determining the MIME type of attachments * @return a template builder already set up with the data from the node * @throws RepositoryException if accessing the repository fails * @throws IOException if reading attachments fails */ public static final Builder builder(final Node template, final ResourceResolver resolver) throws RepositoryException, IOException { return new Builder(template, resolver); } private Map getProperties(final Node subject, final Map extraProperties, final FormUtils formUtils) { final Map actualProperties = new HashMap<>(extraProperties); this.properties.forEach((name, path) -> { try { final Session session = subject.getSession(); if (!path.startsWith("/")) { actualProperties.put(name, path); } else if (session.nodeExists(path)) { final Node questionNode = session.getNode(path); final Collection answers = formUtils.findAllSubjectRelatedAnswers(subject, questionNode, EnumSet.allOf(FormUtils.SearchType.class)); if (!answers.isEmpty()) { Object answer = formUtils.getValue(answers.iterator().next()); actualProperties.put(name, getAnswerValue(answer)); } } else if (session.propertyExists(path)) { actualProperties.put(name, getPropertyValue(session.getProperty(path))); } } catch (RepositoryException e) { // } }); return actualProperties; } private String getAnswerValue(final Object value) { if (value instanceof Object[]) { final Object[] values = (Object[]) value; final List results = new ArrayList<>(); for (Object v : values) { results.add(getSingleAnswerValue(v)); } return StringUtils.join(results, ", "); } else { return getSingleAnswerValue(value); } } private String getSingleAnswerValue(final Object value) { if (value instanceof Calendar) { final DateFormat sdf = DateFormat.getDateInstance(); sdf.setTimeZone(((Calendar) value).getTimeZone()); return sdf.format(((Calendar) value).getTime()); } else { return value.toString(); } } private String getPropertyValue(final Property property) throws RepositoryException { if (property.isMultiple()) { final List results = new ArrayList<>(); for (Value v : property.getValues()) { results.add(getSinglePropertyValue(v)); } return StringUtils.join(results, ", "); } else { return getSinglePropertyValue(property.getValue()); } } private String getSinglePropertyValue(final Value value) throws RepositoryException { if (value.getType() == PropertyType.DATE) { Calendar date = value.getDate(); final DateFormat sdf = DateFormat.getDateInstance(); sdf.setTimeZone(date.getTimeZone()); return sdf.format(date.getTime()); } else { return value.getString(); } } /** * A builder for {@link EmailTemplate}. New instances can be obtained by calling {@link EmailTemplate#builder()} or * {@link EmailTemplate#builder(Node, ResourceResolver)}. * * @version $Id: 69ed4cda81f05bf9a191cd556151efce7f8bec89 $ */ public static final class Builder { private final EmailTemplate instance; private Builder() { this.instance = new EmailTemplate(); } private Builder(final Node template, final ResourceResolver resolver) throws RepositoryException, IOException { this(); readTemplateProperties(template); readCommonAttachments(resolver); readTemplateAttachments(template, resolver); } /** * Set a HTML body template for the email template. Optional, a template can have only a text body, or an actual * email body may be set directly on the email builder. * * @param body a large string * @return same builder instance for method chaining */ public Builder withHtmlTemplate(String body) { this.instance.htmlTemplate = body; return this; } /** * Set a plain text body template for the email template. Optional, a template can have only a HTML body, or an * actual email body may be set directly on the email builder. * * @param body a large string * @return same builder instance for method chaining */ public Builder withTextTemplate(final String body) { this.instance.textTemplate = body; return this; } /** * Set the sender email address. Mandatory. * * @param address a valid email address * @return same builder instance for method chaining */ public Builder withSenderAddress(final String address) { this.instance.senderAddress = address; return this; } /** * Set the sender's display name. Optional. * * @param name a display name * @return same builder instance for method chaining */ public Builder withSenderName(final String name) { this.instance.senderName = name; return this; } /** * Set the sender email address and display name. * * @param address a valid email address * @param name a display name * @return same builder instance for method chaining */ public Builder withSender(final String address, final String name) { this.instance.senderAddress = address; this.instance.senderName = name; return this; } /** * Set the email address to reply to. Optional, defaults to the sender address. * * @param address a valid email address * @return same builder instance for method chaining */ public Builder withReplyToAddress(final String address) { this.instance.replyToAddress = address; return this; } /** * Set the display name for the reply-to email. Optional, defaults to the sender name. * * @param name a display name * @return same builder instance for method chaining */ public Builder withReplyToName(final String name) { this.instance.replyToName = name; return this; } /** * Set the reply-to email address and display name. * * @param address a valid email address * @param name a display name * @return same builder instance for method chaining */ public Builder withReplyTo(final String address, final String name) { this.instance.replyToAddress = address; this.instance.replyToName = name; return this; } /** * Set the email Subject. * * @param subject a short string * @return same builder instance for method chaining */ public Builder withSubject(final String subject) { this.instance.subject = subject; return this; } /** * Add an additional property to be used in the email body when instantiating the template. * * @param name the name of the variable to be used in the body template * @param value a path to a JCR question node, a path to a JCR property, or just a simple string value * @return same builder instance for method chaining */ public Builder withProperty(final String name, final String value) { this.instance.properties.put(name, value); return this; } /** * Add an additional inline attachment to be used in the email. * * @param name the name of the attachment, and the name to be used as the content ID (cid) * @param mimeType the MIME type of the attachment * @param value the actual body as a byte array * @return same builder instance for method chaining */ public Builder withInlineAttachment(final String name, final String mimeType, final byte[] value) { // Clean up any old version of the attachment to allow overriding a common attachment with an // instance-specific one this.instance.inlineAttachments.removeIf(attachment -> attachment.getLeft().equals(name)); this.instance.inlineAttachments.add(new ImmutableTriple<>(name, mimeType, value)); return this; } /** * Retrieve the built {@link EmailTemplate} instance. The builder should be discarded after this. * * @return an {@link EmailTemplate} instance * @throws IllegalStateException if either the sender address or subject are not set */ public EmailTemplate build() throws IllegalStateException { if (this.instance.senderAddress == null || this.instance.subject == null) { throw new IllegalStateException("Email template isn't ready yet"); } return this.instance; } private void readTemplateProperties(final Node template) throws RepositoryException { PropertyIterator properties = template.getProperties(); while (properties.hasNext()) { Property property = properties.nextProperty(); String name = property.getName(); if (name.startsWith("jcr:") || name.startsWith("sling:") || property.isMultiple()) { continue; } String value = property.getValue().getString(); switch (name) { case EmailTemplate.SENDER_ADDRESS_PROPERTY: withSenderAddress(value); break; case EmailTemplate.SENDER_NAME_PROPERTY: withSenderName(value); break; case EmailTemplate.REPLY_TO_ADDRESS_PROPERTY: withReplyToAddress(value); break; case EmailTemplate.REPLY_TO_NAME_PROPERTY: withReplyToName(value); break; case EmailTemplate.SUBJECT_PROPERTY: withSubject(value); break; default: withProperty(name, property.getString()); } } } private void readCommonAttachments(final ResourceResolver resolver) throws RepositoryException, IOException { final Resource commonAttachmentsResource = resolver.getResource(COMMON_ATTACHMENTS_NODE); if (commonAttachmentsResource == null) { return; } final NodeIterator children = commonAttachmentsResource.adaptTo(Node.class).getNodes(); while (children.hasNext()) { Node child = children.nextNode(); if (child.isNodeType("nt:file") && !this.instance.properties.containsKey("skipAttachment_" + child.getName())) { withInlineAttachment(child.getName(), getMimeType(child, resolver), readFileAsBytes(child)); } } } @SuppressWarnings("CyclomaticComplexity") private void readTemplateAttachments(final Node template, final ResourceResolver resolver) throws RepositoryException, IOException { final Session session = template.getSession(); String htmlBodyHeader = session.nodeExists(COMMON_HTML_BODY_HEADER) ? readFileAsString(session.getNode(COMMON_HTML_BODY_HEADER)) : ""; String htmlBody = ""; String htmlBodyFooter = session.nodeExists(COMMON_HTML_BODY_FOOTER) ? readFileAsString(session.getNode(COMMON_HTML_BODY_FOOTER)) : ""; String textBodyHeader = session.nodeExists(COMMON_TEXT_BODY_HEADER) ? readFileAsString(session.getNode(COMMON_TEXT_BODY_HEADER)) : ""; String textBody = ""; String textBodyFooter = session.nodeExists(COMMON_TEXT_BODY_FOOTER) ? readFileAsString(session.getNode(COMMON_TEXT_BODY_FOOTER)) : ""; NodeIterator children = template.getNodes(); while (children.hasNext()) { Node child = children.nextNode(); if (EmailTemplate.HTML_BODY_HEADER.equals(child.getName())) { htmlBodyHeader = readFileAsString(child); } else if (EmailTemplate.HTML_TEMPLATE_NODE.equals(child.getName())) { htmlBody = readFileAsString(child); } else if (EmailTemplate.HTML_BODY_FOOTER.equals(child.getName())) { htmlBodyFooter = readFileAsString(child); } else if (EmailTemplate.TEXT_BODY_HEADER.equals(child.getName())) { textBodyHeader = readFileAsString(child); } else if (EmailTemplate.TEXT_TEMPLATE_NODE.equals(child.getName())) { textBody = readFileAsString(child); } else if (EmailTemplate.TEXT_BODY_FOOTER.equals(child.getName())) { textBodyFooter = readFileAsString(child); } else if (child.isNodeType("nt:file")) { withInlineAttachment(child.getName(), getMimeType(child, resolver), readFileAsBytes(child)); } } withHtmlTemplate(htmlBodyHeader + htmlBody + htmlBodyFooter); withTextTemplate(textBodyHeader + textBody + textBodyFooter); } private String readFileAsString(final Node node) throws IOException, RepositoryException { try { return IOUtils.toString(getFileStream(node), StandardCharsets.UTF_8); } catch (RepositoryException e) { return ""; } } private byte[] readFileAsBytes(final Node node) throws IOException, RepositoryException { return getFileStream(node).readAllBytes(); } private InputStream getFileStream(final Node node) throws RepositoryException { return node.getNode("jcr:content").getProperty("jcr:data").getBinary().getStream(); } private String getMimeType(final Node node, final ResourceResolver resolver) { try { return resolver.getResource(node.getPath()).getResourceMetadata().getContentType(); } catch (Exception e) { return "application/octet-stream"; } } } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy