MailTemplateRenderer.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed 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 org.entrystore.rest.springboot.util;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.entrystore.repository.config.Settings;
import org.jetbrains.annotations.Nullable;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.Year;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * Loads, caches and renders the transactional email templates. Owns everything content-related —
 * subjects, template sources and the {@code __YEAR__} / {@code __DOMAIN__} substitutions — leaving
 * {@code EmailSender} to own transport.
 *
 * <p>The cache is per-bean, not static, so two Spring contexts in one JVM (as in the test suite) do
 * not share or overwrite each other's templates.
 *
 * <p>Population is a deliberate get / load / {@code putIfAbsent} rather than {@code computeIfAbsent}:
 * {@link #loadTemplate} can fetch over HTTP, and {@code computeIfAbsent} would hold the map's bin lock
 * for the duration of that I/O. A concurrent double-load is harmless because the result is
 * idempotent. A failed load is deliberately <em>not</em> cached, so an operator who fixes a bad
 * template path does not need a restart.
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class MailTemplateRenderer {

	private static final int TEMPLATE_FETCH_TIMEOUT_MS = 5_000;

	// The Spring Environment rather than @Value fields: the subject and template-path keys are
	// carried by the EmailTemplate constants, keeping the enum the single source of key names,
	// defaults and bundled resources.
	private final Environment environment;

	private final Map<EmailTemplate, String> templateCache = new ConcurrentHashMap<>();

	/** The configured subject for {@code template}, or its documented default. */
	public String subject(EmailTemplate template) {
		return environment.getProperty(template.getSubjectKey(), template.getDefaultSubject());
	}

	/**
	 * Renders {@code template} with the shared {@code __YEAR__} / {@code __DOMAIN__} substitutions plus
	 * the supplied ones, or returns null when the template cannot be loaded — the same failure signal
	 * the callers turned into a {@code false} return before.
	 *
	 * <p>Substitution values are inserted literally. Entries with a null value are skipped, leaving the
	 * placeholder in place, matching the per-placeholder null guards of the previous implementation.
	 */
	public @Nullable String render(EmailTemplate template, Map<String, String> substitutions) {
		String body = resolveCached(template);
		if (body == null) {
			log.error("Unable to load email template for {}", template.getDescription());
			return null;
		}

		String rendered = body
				// Year.now(), not Calendar.getInstance(): the latter honours the default locale's calendar
				// system, so __YEAR__ renders as 2569 under a th-TH default rather than the Gregorian year.
				.replace("__YEAR__", Integer.toString(Year.now().getValue()))
				.replace("__DOMAIN__", resolveBaseUrlHost(environment));
		for (Map.Entry<String, String> substitution : substitutions.entrySet()) {
			if (substitution.getValue() != null) {
				// Literal replace, never replaceAll: the regex form interprets $ and \ in the
				// *replacement* string as group references and escapes, so a display name or
				// confirmation link containing either would corrupt the mail or throw.
				rendered = rendered.replace(substitution.getKey(), substitution.getValue());
			}
		}
		return rendered;
	}

	private @Nullable String resolveCached(EmailTemplate template) {
		String cached = templateCache.get(template);
		if (cached != null) {
			return cached;
		}
		String loaded = resolveTemplate(template);
		if (loaded == null) {
			return null;
		}
		templateCache.putIfAbsent(template, loaded);
		return loaded;
	}

	/**
	 * Loads the template from the configured path, or the bundled classpath template when no path is
	 * configured. Returns null when the chosen source cannot be loaded — a configured path that fails
	 * to load does not fall back to the classpath template.
	 */
	private @Nullable String resolveTemplate(EmailTemplate template) {
		String templatePath = environment.getProperty(template.getTemplatePathKey());
		if (templatePath != null) {
			return loadTemplate(templatePath);
		}
		return loadClasspathTemplate(template.getClasspathResource());
	}

	/**
	 * Resolves the host of the configured base URL for {@code __DOMAIN__} substitution, returning an
	 * empty string when the base URL is unset, blank, has no host (e.g., a schemeless value such as
	 * {@code store}), or is not a valid URI. Avoids the {@code URI.create(null)} / {@code getHost()}-is-null
	 * NPEs that previously escaped the email helpers; a misconfigured (but non-blank) base URL is logged.
	 */
	static String resolveBaseUrlHost(Environment environment) {
		String baseUrl = environment.getProperty(Settings.BASE_URL);
		if (baseUrl == null || baseUrl.isBlank()) {
			return "";
		}
		try {
			String host = URI.create(baseUrl).getHost();
			if (host == null) {
				log.warn("Configured base URL '{}' has no host; '__DOMAIN__' in emails will be empty", baseUrl);
				return "";
			}
			return host;
		} catch (IllegalArgumentException e) {
			log.warn("Configured base URL '{}' is not a valid URI; '__DOMAIN__' in emails will be empty", baseUrl);
			return "";
		}
	}

	private static @Nullable String loadClasspathTemplate(String resourceName) {
		try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourceName)) {
			if (is == null) {
				// Previously silent, which made a bundled template missing from the jar indistinguishable
				// from a rendering problem. This is the default-configuration path, so it breaks mail for
				// every deployment that does not set an explicit template path.
				log.error("Bundled email template {} is missing from the classpath", resourceName);
				return null;
			}
			return IOUtils.toString(is, StandardCharsets.UTF_8);
		} catch (IOException e) {
			// Stack trace kept, matching loadTemplate: the two failure modes are diagnosed the same way.
			log.error("Failed to load classpath template {}", resourceName, e);
			return null;
		}
	}

	private static @Nullable String loadTemplate(String url) {
		log.debug("Loading template from {}", url);
		try (InputStream is = openTemplate(url)) {
			return IOUtils.toString(is, StandardCharsets.UTF_8);
		} catch (IOException | URISyntaxException e) {
			log.error("Failed to load email template from {}", url, e);
			return null;
		}
	}

	private static InputStream openTemplate(String url) throws IOException, URISyntaxException {
		if (!url.startsWith("http://") && !url.startsWith("https://")) {
			return Files.newInputStream(new File(url).toPath());
		}
		// URL.openStream() inherits the JVM default of no timeout at all, so an unreachable template host
		// would pin one thread per send until the pool is exhausted — and a failed load is deliberately
		// not cached, so every send retries it. These bound the connect and each individual read; note
		// that setReadTimeout is a per-read inactivity timeout, not a deadline for the whole fetch, so a
		// server that drips a byte at a time still holds the thread.
		URLConnection connection = new URI(url).toURL().openConnection();
		connection.setConnectTimeout(TEMPLATE_FETCH_TIMEOUT_MS);
		connection.setReadTimeout(TEMPLATE_FETCH_TIMEOUT_MS);
		return connection.getInputStream();
	}
}