Files
cameleer-saas/docs/superpowers/plans/2026-04-26-email-template-polish-plan.md
hsiegeln cfa9d41b36
All checks were successful
CI / build (push) Successful in 1m54s
CI / docker (push) Successful in 1m2s
docs: add email template polish spec, plan, and update GitNexus index
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 10:37:41 +02:00

19 KiB

Email Template Polish Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace inline HTML email templates with polished, branded HTML files loaded from classpath, featuring playful desert/caravan copy, structured card layout with watermark, and proper header/footer.

Architecture: Extract 4 email templates from EmailConnectorService.buildSmtpConfig() into standalone HTML files at src/main/resources/email-templates/. Generate a pre-faded watermark PNG served as a static asset. Inject ProvisioningProperties to resolve the watermark URL at runtime.

Tech Stack: Java 21, Spring Boot, ImageMagick (one-time asset generation), HTML email (inline styles only)


File Map

Action File Purpose
Create src/main/resources/email-templates/register.html Registration verification email
Create src/main/resources/email-templates/sign-in.html Sign-in verification email
Create src/main/resources/email-templates/forgot-password.html Password reset email
Create src/main/resources/email-templates/generic.html Generic verification email
Create src/main/resources/static/assets/email-watermark.png Pre-faded logo at 7% opacity
Modify src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java Load templates from classpath, inject watermark URL
Modify src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java:49 Permit /assets/** for unauthenticated email clients
Create src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java Verify templates load and placeholders resolve

Task 1: Generate the pre-faded watermark PNG

Files:

  • Create: src/main/resources/static/assets/email-watermark.png

  • Step 1: Generate the faded watermark using ImageMagick

Source the logo from the design-system sibling repo. Apply 7% opacity on a transparent background, output to the static assets directory:

magick "C:/Users/Hendrik/Documents/projects/design-system/assets/cameleer-logo.png" \
  -channel A -evaluate Multiply 0.07 +channel \
  -resize 320x320 \
  "src/main/resources/static/assets/email-watermark.png"

If magick is not available, use Python Pillow as fallback:

python3 -c "
from PIL import Image
img = Image.open('C:/Users/Hendrik/Documents/projects/design-system/assets/cameleer-logo.png').convert('RGBA')
img = img.resize((320, 320), Image.LANCZOS)
r, g, b, a = img.split()
a = a.point(lambda x: int(x * 0.07))
img = Image.merge('RGBA', (r, g, b, a))
img.save('src/main/resources/static/assets/email-watermark.png')
print('Saved watermark')
"
  • Step 2: Verify the file exists and is reasonable size
ls -la src/main/resources/static/assets/email-watermark.png

Expected: File exists, roughly 5-30 KB.

  • Step 3: Commit
git add src/main/resources/static/assets/email-watermark.png
git commit -m "feat: add pre-faded logo watermark for email templates"

Task 2: Permit static assets in SecurityConfig

Files:

  • Modify: src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java:49

The watermark image must be loadable by email clients without authentication. The current security config has .anyRequest().authenticated() as catch-all, so /assets/** needs an explicit permit.

  • Step 1: Add /assets/** to the permitAll list

In SecurityConfig.java, find the existing line:

.requestMatchers("/_app/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()

Change it to:

.requestMatchers("/_app/**", "/assets/**", "/favicon.ico", "/favicon.svg", "/logo.svg", "/logo-dark.svg").permitAll()
  • Step 2: Commit
git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
git commit -m "feat: permit /assets/** for unauthenticated access (email watermark)"

Task 3: Create the 4 HTML email template files

Files:

  • Create: src/main/resources/email-templates/register.html
  • Create: src/main/resources/email-templates/sign-in.html
  • Create: src/main/resources/email-templates/forgot-password.html
  • Create: src/main/resources/email-templates/generic.html

All templates use the same card structure. The {{code}} placeholder is Logto's built-in substitution. The {{watermarkUrl}} placeholder is replaced by EmailConnectorService at runtime.

  • Step 1: Create register.html
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
  <div style="background:#C6820E;padding:20px 24px;text-align:center;">
    <span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
  </div>
  <div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
    <img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
    <div style="position:relative;">
      <p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Welcome to the caravan!</p>
      <p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Enter this code to verify your email and claim your spot. The dunes wait for no one.</p>
      <div style="text-align:center;margin:0 0 24px;">
        <div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
          <span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
        </div>
      </div>
      <p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes. If you didn't request this, you can safely ignore this email — no camels were harmed.</p>
    </div>
  </div>
  <div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
    <p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
    <p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
  </div>
</div>
  • Step 2: Create sign-in.html
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
  <div style="background:#C6820E;padding:20px 24px;text-align:center;">
    <span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
  </div>
  <div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
    <img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
    <div style="position:relative;">
      <p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Back at the oasis already?</p>
      <p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your sign-in code. The caravan master is checking credentials.</p>
      <div style="text-align:center;margin:0 0 24px;">
        <div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
          <span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
        </div>
      </div>
      <p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes.</p>
    </div>
  </div>
  <div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
    <p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
    <p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
  </div>
</div>
  • Step 3: Create forgot-password.html
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
  <div style="background:#C6820E;padding:20px 24px;text-align:center;">
    <span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
  </div>
  <div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
    <img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
    <div style="position:relative;">
      <p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Lost in the dunes?</p>
      <p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">No worries — enter this code to reset your password and get back on the trail.</p>
      <div style="text-align:center;margin:0 0 24px;">
        <div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
          <span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
        </div>
      </div>
      <p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes. If you didn't request a password reset, you can safely ignore this email.</p>
    </div>
  </div>
  <div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
    <p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
    <p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
  </div>
</div>
  • Step 4: Create generic.html
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
  <div style="background:#C6820E;padding:20px 24px;text-align:center;">
    <span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
  </div>
  <div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
    <img src="{{watermarkUrl}}" width="320" height="320" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;" alt="" />
    <div style="position:relative;">
      <p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Quick checkpoint</p>
      <p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 24px;">Here's your verification code. Just making sure it's really you at the reins.</p>
      <div style="text-align:center;margin:0 0 24px;">
        <div style="display:inline-block;background:#FDF6EC;border:2px solid #C6820E;border-radius:8px;padding:16px 32px;">
          <span style="font-size:32px;font-weight:700;letter-spacing:8px;color:#C6820E;font-family:'Courier New',Courier,monospace;">{{code}}</span>
        </div>
      </div>
      <p style="color:#888;font-size:13px;line-height:1.5;margin:0;">This code expires in 10 minutes.</p>
    </div>
  </div>
  <div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
    <p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
    <p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
  </div>
</div>
  • Step 5: Commit
git add src/main/resources/email-templates/
git commit -m "feat: add branded HTML email templates with desert/caravan copy"

Task 4: Refactor EmailConnectorService to load templates from classpath

Files:

  • Modify: src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java

  • Step 1: Write the failing test

Create src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java:

package net.siegeln.cameleer.saas.vendor;

import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

import static org.junit.jupiter.api.Assertions.*;

class EmailTemplateLoadingTest {

    private static final String[] TEMPLATE_FILES = {
        "email-templates/register.html",
        "email-templates/sign-in.html",
        "email-templates/forgot-password.html",
        "email-templates/generic.html"
    };

    @Test
    void allTemplateFilesExistOnClasspath() {
        for (String path : TEMPLATE_FILES) {
            var resource = new ClassPathResource(path);
            assertTrue(resource.exists(), "Template file missing: " + path);
        }
    }

    @Test
    void templatesContainCodePlaceholder() throws IOException {
        for (String path : TEMPLATE_FILES) {
            String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
            assertTrue(content.contains("{{code}}"),
                path + " must contain {{code}} placeholder");
        }
    }

    @Test
    void templatesContainWatermarkPlaceholder() throws IOException {
        for (String path : TEMPLATE_FILES) {
            String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
            assertTrue(content.contains("{{watermarkUrl}}"),
                path + " must contain {{watermarkUrl}} placeholder");
        }
    }

    @Test
    void watermarkPlaceholderIsReplaced() throws IOException {
        String content = new ClassPathResource("email-templates/register.html")
            .getContentAsString(StandardCharsets.UTF_8);
        String resolved = content.replace("{{watermarkUrl}}",
            "https://example.com/platform/assets/email-watermark.png");
        assertFalse(resolved.contains("{{watermarkUrl}}"));
        assertTrue(resolved.contains("https://example.com/platform/assets/email-watermark.png"));
    }

    @Test
    void templatesContainBrandElements() throws IOException {
        for (String path : TEMPLATE_FILES) {
            String content = new ClassPathResource(path).getContentAsString(StandardCharsets.UTF_8);
            assertTrue(content.contains("Cameleer.io"),
                path + " must contain Cameleer.io header");
            assertTrue(content.contains("Apache Camel observability"),
                path + " must contain tagline");
            assertTrue(content.contains("#C6820E"),
                path + " must use brand color");
        }
    }
}
  • Step 2: Run tests to verify they pass (templates exist from Task 3)
./mvnw test -pl . -Dtest=EmailTemplateLoadingTest -Dspring.profiles.active=test

Expected: All 5 tests PASS.

  • Step 3: Add ProvisioningProperties dependency to EmailConnectorService

Replace the constructor and add the template loading logic. The full updated EmailConnectorService.java:

Change the imports and fields at the top of the class — add ProvisioningProperties import and field:

import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
import org.springframework.core.io.ClassPathResource;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

Replace the constructor:

    private final LogtoManagementClient logtoClient;
    private final ProvisioningProperties provisioningProps;

    public EmailConnectorService(LogtoManagementClient logtoClient, ProvisioningProperties provisioningProps) {
        this.logtoClient = logtoClient;
        this.provisioningProps = provisioningProps;
    }

Replace the buildSmtpConfig method (lines 157-191) with:

    /** Load an email template from classpath and resolve the watermark URL placeholder. */
    private String loadTemplate(String filename) {
        try {
            String content = new ClassPathResource("email-templates/" + filename)
                .getContentAsString(StandardCharsets.UTF_8);
            String watermarkUrl = provisioningProps.publicProtocol() + "://"
                + provisioningProps.publicHost() + "/platform/assets/email-watermark.png";
            return content.replace("{{watermarkUrl}}", watermarkUrl);
        } catch (IOException e) {
            throw new IllegalStateException("Failed to load email template: " + filename, e);
        }
    }

    /** Build the Logto SMTP connector config with Cameleer-branded email templates. */
    private Map<String, Object> buildSmtpConfig(SmtpConfig smtp) {
        var config = new HashMap<String, Object>();
        config.put("host", smtp.host());
        config.put("port", smtp.port());
        config.put("auth", Map.of("user", smtp.username(), "pass", smtp.password()));
        config.put("fromEmail", smtp.fromEmail());
        config.put("templates", List.of(
            Map.of(
                "usageType", "Register",
                "contentType", "text/html",
                "subject", "Your caravan pass is almost ready",
                "content", loadTemplate("register.html")
            ),
            Map.of(
                "usageType", "SignIn",
                "contentType", "text/html",
                "subject", "Your Cameleer sign-in code",
                "content", loadTemplate("sign-in.html")
            ),
            Map.of(
                "usageType", "ForgotPassword",
                "contentType", "text/html",
                "subject", "Reset your Cameleer password",
                "content", loadTemplate("forgot-password.html")
            ),
            Map.of(
                "usageType", "Generic",
                "contentType", "text/html",
                "subject", "Your Cameleer verification code",
                "content", loadTemplate("generic.html")
            )
        ));
        return config;
    }
  • Step 4: Verify the project compiles
./mvnw compile -pl .

Expected: BUILD SUCCESS

  • Step 5: Run the template tests again to confirm nothing broke
./mvnw test -pl . -Dtest=EmailTemplateLoadingTest -Dspring.profiles.active=test

Expected: All 5 tests PASS.

  • Step 6: Commit
git add src/main/java/net/siegeln/cameleer/saas/vendor/EmailConnectorService.java
git add src/test/java/net/siegeln/cameleer/saas/vendor/EmailTemplateLoadingTest.java
git commit -m "feat: load email templates from classpath with watermark URL resolution"

Task 5: Run the full test suite

Files: None (verification only)

  • Step 1: Run all tests
./mvnw test -Dspring.profiles.active=test

Expected: BUILD SUCCESS, all tests pass. If any existing tests fail due to the new ProvisioningProperties constructor parameter on EmailConnectorService, they will need their mocks updated — but there are no existing tests for this class.

  • Step 2: Verify the watermark is accessible without auth by checking SecurityConfig

Confirm the /assets/** matcher is in the permitAll() chain (done in Task 2). With context-path /platform, the full public URL will be https://<host>/platform/assets/email-watermark.png.

  • Step 3: Final commit if any fixes were needed

Only if test failures required changes:

git add -A
git commit -m "fix: resolve test failures from email template refactor"