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 本身不能证明邮箱未注册,邮件措辞应避免据此断言账号不存在。发送前应校验收件地址,并对这类由外部输入触发的邮件设置频率限制,避免同一邮箱被反复通知。
备注:内容仅供参考。
