Resolver Types
mjml-java provides a pluggable IncludeResolver system for resolving <mj-include> paths. The core module includes basic resolvers, and the mjml-java-resolvers module adds advanced implementations.
Core Resolvers (mjml-java-core)
These resolvers ship with the core module and have zero external dependencies.
FileSystemIncludeResolver
Resolves include paths relative to a base directory on the file system. Includes path traversal protection.
import dev.jcputney.mjml.FileSystemIncludeResolver;
IncludeResolver resolver = new FileSystemIncludeResolver(Path.of("/templates"));
MjmlConfiguration config = MjmlConfiguration.builder()
.includeResolver(resolver)
.build();
Given <mj-include path="partials/header.mjml" />, the resolver reads /templates/partials/header.mjml. Paths that escape the base directory via ../ are rejected.
ClasspathIncludeResolver
Resolves include paths from the Java classpath (e.g., resources bundled in a JAR).
import dev.jcputney.mjml.ClasspathIncludeResolver;
// Uses the current thread's context class loader
IncludeResolver resolver = new ClasspathIncludeResolver();
// Or specify a class loader explicitly
IncludeResolver resolver = new ClasspathIncludeResolver(MyApp.class.getClassLoader());
Includes the same path traversal protections as FileSystemIncludeResolver.
Additional Resolvers (mjml-java-resolvers)
Add the resolvers module to your project:
<dependency>
<groupId>dev.jcputney</groupId>
<artifactId>mjml-java-resolvers</artifactId>
<version>1.0.1</version>
</dependency>
All resolvers in this module are in the dev.jcputney.mjml.resolver package.
MapIncludeResolver
An in-memory resolver backed by a Map<String, String>. Useful for testing or embedding templates directly in code.
import dev.jcputney.mjml.resolver.MapIncludeResolver;
// From a map
MapIncludeResolver resolver = new MapIncludeResolver(Map.of(
"header.mjml", "<mj-section><mj-column><mj-text>Header</mj-text></mj-column></mj-section>",
"footer.mjml", "<mj-section><mj-column><mj-text>Footer</mj-text></mj-column></mj-section>"
));
// From alternating path/content pairs
MapIncludeResolver resolver = MapIncludeResolver.of(
"header.mjml", "<mj-section>...</mj-section>",
"footer.mjml", "<mj-section>...</mj-section>"
);
// Using the builder
MapIncludeResolver resolver = MapIncludeResolver.builder()
.put("header.mjml", "<mj-section>...</mj-section>")
.put("footer.mjml", "<mj-section>...</mj-section>")
.build();
CompositeIncludeResolver
Chains multiple resolvers together. The first resolver that succeeds wins; if all fail, the last exception is rethrown.
import dev.jcputney.mjml.resolver.CompositeIncludeResolver;
IncludeResolver resolver = CompositeIncludeResolver.of(
new ClasspathIncludeResolver(),
new FileSystemIncludeResolver(Path.of("/templates"))
);
Or from a list:
IncludeResolver resolver = new CompositeIncludeResolver(List.of(
classpathResolver,
fileSystemResolver,
httpResolver
));
CachingIncludeResolver
A caching decorator with TTL-based expiration and configurable maximum entries. Thread-safe.
import dev.jcputney.mjml.resolver.CachingIncludeResolver;
IncludeResolver resolver = CachingIncludeResolver.builder()
.delegate(new FileSystemIncludeResolver(Path.of("/templates")))
.ttl(Duration.ofMinutes(5)) // Default: 5 minutes
.maxEntries(256) // Default: 256
.build();
Cache management methods:
| Method | Description |
|---|---|
invalidateAll() | Removes all entries from the cache |
invalidate(String path) | Removes a single entry |
size() | Returns the current number of cached entries |
When the cache is full, expired entries are evicted first, then the oldest 25% are removed.
Cache entries are keyed by include path plus resolver context (includingPath and includeType), so context-sensitive delegates are cached safely.
ttl must be a positive duration and maxEntries must be greater than 0.
UrlIncludeResolver
Fetches content via HTTP/HTTPS using the JDK HttpClient. Includes built-in SSRF protection by blocking requests to private/loopback addresses.
import dev.jcputney.mjml.resolver.UrlIncludeResolver;
IncludeResolver resolver = UrlIncludeResolver.builder()
.allowedHosts("cdn.example.com", "templates.example.com")
.httpsOnly(true) // Default: true
.connectTimeout(Duration.ofSeconds(5)) // Default: 5 seconds
.readTimeout(Duration.ofSeconds(10)) // Default: 10 seconds
.maxResponseSize(1_048_576) // Default: 1 MB
.build();
Builder options:
| Method | Default | Description |
|---|---|---|
allowedHosts(String...) | (empty) | Required for hostname URLs; if non-empty, only these hosts are permitted |
deniedHosts(String...) | (empty) | These hosts are always blocked |
httpsOnly(boolean) | true | Restrict to HTTPS URLs only |
connectTimeout(Duration) | 5 seconds | Connection timeout |
readTimeout(Duration) | 10 seconds | Request/read timeout |
maxResponseSize(int) | 1 MB | Maximum response body size in bytes |
httpClient(HttpClient) | (auto-created) | Custom HttpClient (useful for testing) |
UrlIncludeResolver automatically blocks requests to loopback, link-local, site-local (IPv4), any-local, multicast, and IPv6 unique-local (fc00::/7) addresses. This protects against SSRF attacks where an attacker uses <mj-include> to probe internal network resources.
For hostname URLs (for example https://cdn.example.com/file.mjml), configure allowedHosts(...). Without an explicit allowlist, hostname requests are rejected.
Hostnames configured via allowedHosts(...) and deniedHosts(...) are normalized (trimmed + lowercased) at build time.
PrefixRoutingIncludeResolver
Routes include paths to different resolvers based on prefix matching. The prefix is stripped before delegation.
import dev.jcputney.mjml.resolver.PrefixRoutingIncludeResolver;
IncludeResolver resolver = PrefixRoutingIncludeResolver.builder()
.route("classpath:", new ClasspathIncludeResolver())
.route("https://", UrlIncludeResolver.builder()
.allowedHosts("cdn.example.com")
.build())
.defaultResolver(new FileSystemIncludeResolver(Path.of("/templates")))
.build();
With this configuration:
<mj-include path="classpath:templates/header.mjml" />resolves viaClasspathIncludeResolverwith pathtemplates/header.mjml<mj-include path="https://cdn.example.com/footer.mjml" />resolves viaUrlIncludeResolverwith pathcdn.example.com/footer.mjml<mj-include path="partials/nav.mjml" />resolves via the defaultFileSystemIncludeResolver
Prefixes are matched in insertion order.
Custom Resolvers
Implement the IncludeResolver functional interface for any custom resolution strategy:
IncludeResolver dbResolver = (path, context) -> {
// context.includeType() — "mjml", "html", "css", or "css-inline"
// context.depth() — nesting depth (0 for top-level)
String content = templateRepository.findByPath(path);
if (content == null) {
throw new MjmlIncludeException("Template not found: " + path);
}
return content;
};
Combining Resolvers
A common pattern is to combine several resolvers:
// Cache HTTP results, fall back to classpath for local templates
IncludeResolver resolver = CompositeIncludeResolver.of(
CachingIncludeResolver.builder()
.delegate(UrlIncludeResolver.builder()
.allowedHosts("templates.example.com")
.build())
.ttl(Duration.ofMinutes(10))
.build(),
new ClasspathIncludeResolver()
);