PAYATHON 2026

Keycloak 26 如何在不创建用户的情况下发送自定义模板邮件?

数据小陈

可以先用 FreeMarkerProvider 渲染自定义邮件模板,再调用 EmailSenderProvider 接收邮箱地址的 send(...) 重载发送邮件。这个过程无需创建 UserModel,也不需要写入用户数据库。

以下实现依据 Keycloak 26.0.0 的源码;使用其他 26.x 版本时,应对照实际依赖确认 SPI 接口。

为什么默认邮件模板实现会要求用户

在 26.0.0 的 FreeMarkerEmailTemplateProvider 中,渲染前会创建 ProfileBean(user, session)。ProfileBean 的构造方法会访问 user.getAttributes(),因此即使自定义模板没有引用 ${user...},传入空用户仍会失败。相关行为见 FreeMarkerEmailTemplateProvider 源码 和 ProfileBean 源码。

底层的 FreeMarkerProvider.processTemplate(attributes, templateName, theme) 接收模板变量、模板名称和主题,无需传入用户。只有邮箱地址时,可以用它渲染模板,再发送邮件。

准备自定义邮件模板

假设主题名为 custom-mail,使用以下目录结构,并在目标 realm 的 Email theme 设置中选择该主题:

themes/
└── custom-mail/
    └── email/
        ├── theme.properties
        ├── text/
        │   └── reset-password-notice.ftl
        └── html/
            └── reset-password-notice.ftl

theme.properties:

parent=base

纯文本模板 text/reset-password-notice.ftl:

你收到了一条来自 ${realmName} 的密码重置请求通知。

请求中填写的邮箱地址为:${recipientEmail}
如果不是你本人操作,可以忽略这封邮件。

HTML 模板 html/reset-password-notice.ftl:

<#ftl output_format="HTML" auto_esc=true>
<!DOCTYPE html>
<html lang="zh-CN">
<body>
  <p>你收到了一条来自 ${realmName} 的密码重置请求通知。</p>
  <p>请求中填写的邮箱地址为:${recipientEmail}</p>
  <p>如果不是你本人操作,可以忽略这封邮件。</p>
</body>
</html>

这两个模板只使用显式提供的变量。HTML 模板启用了转义,避免把邮箱等动态值直接解释为 HTML。

渲染模板并发送邮件

现有监听器可以调用下面的辅助类。使用前,请确保 session 上下文中的 realm 对应当前事件,且该 realm 已配置 SMTP 和 Email theme。

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import org.keycloak.email.EmailException;
import org.keycloak.email.EmailSenderProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.theme.FreeMarkerException;
import org.keycloak.theme.Theme;
import org.keycloak.theme.freemarker.FreeMarkerProvider;

public final class NotificationEmail {

    private NotificationEmail() {
    }

    public static void send(KeycloakSession session, String recipient)
            throws IOException, FreeMarkerException, EmailException {
        RealmModel realm = session.getContext().getRealm();
        if (realm == null) {
            throw new IllegalStateException("Realm context is required");
        }
        if (recipient == null || recipient.isBlank()) {
            throw new IllegalArgumentException("Recipient is required");
        }

        Theme theme = session.theme().getTheme(Theme.Type.EMAIL);
        FreeMarkerProvider freeMarker =
                session.getProvider(FreeMarkerProvider.class);

        String realmName = realm.getDisplayName();
        if (realmName == null || realmName.isBlank()) {
            realmName = realm.getName();
        }

        Map<String, Object> attributes = new HashMap<>();
        attributes.put("realmName", realmName);
        attributes.put("recipientEmail", recipient);

        String textBody = freeMarker.processTemplate(
                attributes, "text/reset-password-notice.ftl", theme);
        String htmlBody = freeMarker.processTemplate(
                attributes, "html/reset-password-notice.ftl", theme);

        EmailSenderProvider sender =
                session.getProvider(EmailSenderProvider.class);
        sender.send(
                realm.getSmtpConfig(),
                recipient,
                "密码重置请求通知",
                textBody,
                htmlBody);
    }
}

这里使用的发送方法是:

send(config, address, subject, textBody, htmlBody)

第二个参数是 String 类型的邮箱地址。默认发送实现支持同时传入纯文本和 HTML 正文,详见 DefaultEmailSenderProvider 源码。已有的普通邮件发送逻辑可以继续使用,将正文替换为模板渲染结果即可。

在监听器的 onEvent(...) 中筛选 RESET_PASSWORD_ERROR,提取并校验邮箱后,调用 NotificationEmail.send(session, email)。示例将异常交给调用方处理;实际监听器应记录模板或 SMTP 错误,并按业务要求决定失败后的处理方式。

直接渲染时需要补齐哪些变量

直接调用 FreeMarkerProvider 不会执行默认邮件模板 provider 中准备变量的逻辑。示例只提供 realmName 和 recipientEmail,不能假定 ${user.email}、${msg(...)}、${url...} 或其他默认变量可用。

如果复用现有主题模板,需要检查模板本身及其导入的布局文件,按实际引用补齐变量。这个场景使用不依赖 user 的独立模板最容易维护。示例邮件主题固定为中文;需要多语言时,再增加明确的语言选择、消息加载和主题格式化逻辑。

是否需要临时 UserModel

这个方案不需要临时用户对象。默认邮件模板实现要求一个可用的 UserModel,但该对象不一定要来自持久化数据库。如果自行实现内存中的用户对象,仍需满足 ProfileBean 和所用模板对属性的访问要求,不能只提供一个邮箱就假定所有路径都能工作。

只向邮箱地址发送自定义通知时,直接渲染模板可以省去这些适配工作。

另外,RESET_PASSWORD_ERROR 本身不能证明邮箱未注册,邮件措辞应避免据此断言账号不存在。发送前应校验收件地址,并对这类由外部输入触发的邮件设置频率限制,避免同一邮箱被反复通知。

备注:内容仅供参考。