Skip to main content

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:

MethodDescription
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:

MethodDefaultDescription
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)trueRestrict to HTTPS URLs only
connectTimeout(Duration)5 secondsConnection timeout
readTimeout(Duration)10 secondsRequest/read timeout
maxResponseSize(int)1 MBMaximum response body size in bytes
httpClient(HttpClient)(auto-created)Custom HttpClient (useful for testing)
SSRF Protection

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 via ClasspathIncludeResolver with path templates/header.mjml
  • <mj-include path="https://cdn.example.com/footer.mjml" /> resolves via UrlIncludeResolver with path cdn.example.com/footer.mjml
  • <mj-include path="partials/nav.mjml" /> resolves via the default FileSystemIncludeResolver

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