关于 extension-email-marketing
此 Caffeine AI 扩展为应用提供营销邮件发送、主题订阅管理和退订流程,适合需要向订阅者发送定向邮件的应用。发送对象限于已验证邮箱且订阅相应主题的用户,并提供应用内退订链接。
主要功能
- 01管理主题及订阅者
- 02仅向已验证订阅者发送
- 03按收件人个性化邮件
- 04生成主题对应的退订链接
- 05提供公开退订页面
适用场景
- 01向特定主题订阅者发送邮件
- 02维护营销邮件订阅名单
- 03让收件人自行退订主题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ caffeinelabs/ skills --skill extension-email-marketing安装后在 Claude Code 中输入/extension-email-marketing确认已加载。
详细介绍
Email — 营销
适用于 Caffeine AI 的营销邮件扩展。
概述
此技能添加了直接营销邮件支持,包括订阅者管理、基于主题的订阅,以及指向应用自身前端的退订链接。后端为每位订阅者和主题生成一个退订令牌(unsubscribeTokens),并通过 MixinEmailUnsubscribe 处理;前端渲染 /unsubscribe 页面,收件人可在其中点击按钮退订。用户必须先验证邮箱,才能接收营销邮件。
必需的设置清单
以下六个步骤均为必需。
- mops 依赖项 —
mops add caffeineai-email-marketing、mops add caffeineai-email-verification和mops add caffeineai-authorization。 - 稳定状态字段 —
emailSubscribers : EmailSubscribers.State和unsubscribeTokens : UnsubscribeTokens.State(以及验证状态verifiedEmails和verificationTokens),并在迁移链头部初始化。 - Mixin 调用 — 在
main.mo中加入include MixinEmailUnsubscribe(emailSubscribers, unsubscribeTokens)和include MixinEmailVerification(verifiedEmails, verificationTokens)。 - 发送 —
EmailMarketing.sendMarketingEmail(emailSubscribers, verifiedEmails, unsubscribeTokens, topicId, fromUsername, subject, htmlBody, personalise)会向该主题的所有已验证订阅者发送邮件;应用绝不会自行组装收件人列表。sendMarketingEmailTo接收明确指定的地址,并只向其中属于该主题已验证订阅者的地址发送邮件。 - 退订链接 — 正文应包含
{{UNSUBSCRIBE_URL}};如果缺少此占位符,系统会追加一行退订说明。每封邮件还会附带一个指向同一页面的List-Unsubscribe标头。 - 前端 npm 包和路由 — 安装
@caffeineai/email-marketing,并设置一个公开路由/unsubscribe(无需登录即可访问),用于渲染一个调用confirm()的按钮。
重要:退订链接格式为 https://<app domain>/unsubscribe?token=…。如果没有前端路由,收件人将进入不存在的页面,无法退订,这会导致合规问题。
后端
此组件用于发送直接营销邮件和管理营销主题的订阅者。
- 用户必须验证其邮箱地址,且必须订阅某个营销主题,才能接收该主题的营销邮件
- 营销邮件必须包含一个退订链接,用于取消用户对指定主题的订阅
- 此组件依赖用于验证邮箱地址的 extension-email-verification,请务必一并查看。
订阅营销主题并管理订阅者列表
- 使用预制模块
mo:caffeineai-email-marketing/subscribers.mo,该模块不可修改。 - 营销邮件订阅者必须完全通过此订阅者模块处理
- 不要在用户个人资料中另外存储订阅状态
- 重要:营销邮件只能发送给通过
EmailSubscribers.add添加的主题已验证订阅者。sendMarketingEmail会自行读取这些订阅者,而sendMarketingEmailTo会排除所有其他地址,因为_caffeineEmailUnsubscribe会拒绝不属于该主题的地址的退订链接,这会导致合规问题。
module {
public type State = {
var topics : Map.Map<Nat, TopicRecord>;
var topicsByName : Map.Map<Text, Nat>;
};
// Add a new topic by name or get an existing topic if it already exists. Returns the topic ID.
public func addTopic(state : State, name : Text) : Nat;
// Rename a topic. Returns false if a topic with the new name already exists or the topic ID does not exist.
public func renameTopic(state : State, topicId : Nat, newName : Text) : Bool;
// Remove a topic. Also removes all subscribers from that topic; their unsubscribe links stop working.
public func removeTopic(state : State, tokens : UnsubscribeTokens.State, topicId : Nat);
// List all topics (id, name).
public func listTopics(state : State) : [Topic];
// Get a topic ID by name
public func getTopicId(state : State, name : Text) : ?Nat;
// Get a topic name by ID
public func getTopicName(state : State, topicId : Nat) : ?Text;
// Add a subscriber to a topic. Returns false if the topic doesn't exist
public func add(state : State, topicId : Nat, email : Text) : Bool;
// Remove a subscriber from a topic. Their unsubscribe link stops working; a re-subscription gets a fresh one on the next send.
public func remove(state : State, tokens : UnsubscribeTokens.State, topicId : Nat, email : Text);
// Remove a subscriber from all topics
public func removeFromAllTopics(state : State, tokens : UnsubscribeTokens.State, email : Text);
// List all subscribers alongside their verification status for a given topic. Returns null if the topic doesn't exist.
public func list(state : State, verifiedEmails : VerifiedEmails.State, topicId : Nat) : ?[(Text, Bool)];
// List all verified subscribers for a given topic. Returns null if the topic doesn't exist.
public func verified(state : State, verifiedEmails : VerifiedEmails.State, topicId : Nat) : ?[Text];
// Return whether a subscriber is subscribed to a topic
public func isSubscribed(state : State, topicId : Nat, email : Text) : Bool;
// List all topics a subscriber is subscribed to.
public func listTopicsForSubscriber(state : State, email : Text) : [Topic];
// Returns the count of subscribers for a given topic
public func count(state : State, topicId : Nat) : Nat;
// Returns the count of verified subscribers for a given topic
public func verifiedCount(state : State, verifiedEmails : VerifiedEmails.State, topicId : Nat) : Nat;
};
退订链接令牌
使用预制模块 mo:caffeineai-email-marketing/unsubscribeTokens.mo,该模块不可修改。它为每位订阅者和主题保存一个令牌,在首次发送时生成并保留,以确保旧邮件中的链接继续有效;收件人使用该令牌后,系统会将其删除。应用只需声明状态并传递该状态。
module {
public type State = {
byToken : Map.Map<Text, { email : Text; topicId : Nat }>;
bySubscription : Map.Map<Text, Text>;
};
public func new() : State;
public func size(state : State) : Nat;
};
处理取消订阅链接
使用预制模块 mo:caffeineai-email-marketing/unsubscribeMixin.mo,该模块不可修改。
MixinEmailUnsubscribe 接收订阅者状态和令牌状态。它会添加 _caffeineEmailUnsubscribe(token),应用的前端会在取消订阅页面调用此函数:它会将订阅者从令牌签发时对应的主题中移除,并返回主题名称。任何持有有效令牌的人都可以调用此函数,包括匿名主体,因此收件人无需拥有账户。它还保留了旧版 _caffeineEmailUnsubscribeFromTopic 回调,因此传输 Canister 在 0.2.0 之前发送的营销邮件中的链接,在传输服务弃用前仍可正常使用。
import MixinEmailUnsubscribe "mo:caffeineai-email-marketing/unsubscribeMixin";
mixin (subscribers : Subscribers.State, unsubscribeTokens : UnsubscribeTokens.State) {
public shared func _caffeineEmailUnsubscribe(token : Text) : async { #ok : { topic : ?Text }; #err : Text };
};
从后端发送直接营销邮件
- 使用预制模块
mo:caffeineai-email-marketing/marketing.mo中的sendMarketingEmail函数,该模块不可修改。切勿在应用代码中调用EmailClient.broadcast:此模块会准备令牌、链接模板和发送时使用的List-Unsubscribe标头。 - 必须与 subscribers.mo 模块和 unsubscribeMixin.mo 模块配合使用。
sendMarketingEmail会向该主题的所有已验证订阅者发送邮件。其personalise参数是一个从电子邮件地址映射到该收件人替换名称/值对的函数(没有替换项时为[])。- 这些替换项可用于为每位收件人个性化邮件。
- 如果邮件正文包含用双花括号括起来的替换名称,则发送给该收件人的邮件中会将其替换为对应的替换值。
sendMarketingEmailTo会向明确指定的地址发送邮件,并将收件人限制为该主题的已验证订阅者;它使用相同的personalise参数。如果没有符合条件的收件人,则返回#err。- 它会返回一个 Result:邮件发送成功时为 #ok,否则为包含错误文本的 #err(error)。
htmlBody中的占位文本 {{UNSUBSCRIBE_URL}} 会替换为应用/unsubscribe页面上的收件人取消订阅链接;如果缺少此占位文本,则会在正文末尾追加取消订阅行。每封邮件副本还会携带一个包含相同链接的List-Unsubscribe标头,因此邮件客户端会显示自己的取消订阅控件。- 邮件会通过邮件网关发送,并使用平台在每次部署时注入的设置(
INTEGRATIONS_GATEWAY_URL/INTEGRATIONS_GATEWAY_API_KEY);没有这些设置时,函数会返回#err。
module {
// To every verified subscriber of the topic; `personalise` gives each
// recipient's substitutions. #err when the topic is unknown or empty.
public func sendMarketingEmail(
subscribers : Subscribers.State,
verifiedEmails : VerifiedEmails.State,
tokens : UnsubscribeTokens.State,
topicId : Nat,
fromUsername : Text,
subject : Text,
htmlBody : Text,
personalise : Text -> [(Text, Text)],
) : async { #ok; #err : Text };
// To those of the given addresses that are verified subscribers of the
// topic. #err when the topic is unknown or none of them is.
public func sendMarketingEmailTo(
subscribers : Subscribers.State,
verifiedEmails : VerifiedEmails.State,
tokens : UnsubscribeTokens.State,
topicId : Nat,
fromUsername : Text,
emails : [Text],
subject : Text,
htmlBody : Text,
personalise : Text -> [(Text, Text)],
) : async { #ok; #err : Text };
// The addresses either function would send to: the topic's verified
// subscribers, or those of them in `among`. Null for an unknown topic.
public func recipients(
subscribers : Subscribers.State,
verifiedEmails : VerifiedEmails.State,
topicId : Nat,
among : ?[Text],
) : ?[Text];
};
可向订阅由管理员管理的主题的用户发送营销邮件的应用示例
import Array "mo:core/Array";
import Runtime "mo:core/Runtime";
import Option "mo:core/Option";
import Principal "mo:core/Principal";
import Iter "mo:core/Iter";
import Map "mo:core/Map";
import Set "mo:core/Set";
import Text "mo:core/Text";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import EmailMarketing "mo:caffeineai-email-marketing/marketing";
import MixinEmailUnsubscribe "mo:caffeineai-email-marketing/unsubscribeMixin";
import EmailSubscribers "mo:caffeineai-email-marketing/subscribers";
import UnsubscribeTokens "mo:caffeineai-email-marketing/unsubscribeTokens";
import EmailVerification "mo:caffeineai-email-verification/verification";
import MixinEmailVerification "mo:caffeineai-email-verification/verificationMixin";
import VerificationTokens "mo:caffeineai-email-verification/verificationTokens";
import VerifiedEmails "mo:caffeineai-email-verification/verifiedEmails";
actor {
public type UserProfile = {
name : Text;
email : Text;
};
// Include authorization component
let accessControlState : AccessControl.AccessControlState;
include MixinAuthorization(accessControlState, null);
// Store a map of caller principal to UserProfile
let userProfiles : Map.Map<Principal, UserProfile>;
// Store a set of emails for uniqueness check
let emails : Set.Set<Text>;
// Stores which emails are verified
let verifiedEmails : VerifiedEmails.State;
// Pending verification links, until clicked or expired
let verificationTokens : VerificationTokens.State;
// In this example we use a single hardcoded topic.
// In general there could be CRUD endpoints for the admin to manage email subscription topics.
transient let newsletterTopic = "Newsletter";
// Store the email subscribers per topic
let emailSubscribers : EmailSubscribers.State;
// Unsubscribe link tokens, one per subscriber and topic
let unsubscribeTokens : UnsubscribeTokens.State;
// Include this mixin to handle the unsubscribe link which updates the EmailSubscribers state
include MixinEmailUnsubscribe(emailSubscribers, unsubscribeTokens);
// Include this mixin to handle the verification link which updates the VerifiedEmails state
include MixinEmailVerification(verifiedEmails, verificationTokens);
func getUserInternal(caller : Principal) : UserProfile {
userProfiles.get(caller) ?? Runtime.trap("User profile does not exist!");
};
public shared ({ caller }) func registerUser(name : Text, email : Text) : async () {
// Check if the user already exists
if (userProfiles.containsKey(caller)) {
Runtime.trap("User already registered");
};
// Check if the email is already used
if (emails.contains(email)) {
Runtime.trap("Email already taken");
};
// Add a user record
userProfiles.add(
caller,
{
name;
email;
},
);
emails.add(email);
// Subscribe the user to the Newsletter topic by default
let topicId = EmailSubscribers.getTopicId(emailSubscribers, newsletterTopic)
?? Runtime.trap("Newsletter topic not found");
ignore EmailSubscribers.add(emailSubscribers, topicId, email);
// Send a verification email
let result = await EmailVerification.sendVerificationEmail(
verificationTokens,
"no-reply",
[email],
"Welcome to Our Service",
"Hello " # name # ",<br><br>Thank you for registering with our service.<br><br>Please <a href=\"{{VERIFICATION_URL}}\">click here</a> to verify your email address.<br><br>By clicking on the verification link you also agree to sign-up to the monthly Newsletter which you can unsubscribe from at any time.<br><br>Best regards,<br>The Team",
);
switch (result) {
case (#ok) {};
case (#err(error)) {
Runtime.trap("Failed to send verification email: " # error);
};
};
};
public shared ({ caller }) func addTopic(name : Text) : async Nat {
if (not (AccessControl.hasPermission(accessControlState, caller, #admin))) {
Runtime.trap("Unauthorized: Only admins can add topics");
};
EmailSubscribers.addTopic(emailSubscribers, name);
};
public shared ({ caller }) func removeTopic(topicId : Nat) : async () {
if (not (AccessControl.hasPermission(accessControlState, caller, #admin))) {
Runtime.trap("Unauthorized: Only admins can remove topics");
};
EmailSubscribers.removeTopic(emailSubscribers, unsubscribeTokens, topicId);
};
public shared ({ caller }) func renameTopic(topicId : Nat, newName : Text) : async () {
if (not (AccessControl.hasPermission(accessControlState, caller, #admin))) {
Runtime.trap("Unauthorized: Only admins can rename topics");
};
let success = EmailSubscribers.renameTopic(emailSubscribers, topicId, newName);
if (not success) {
Runtime.trap("Failed to rename topic");
};
};
public shared ({ caller }) func subscribeToTopic(topicId : Nat) : async () {
let userProfile = getUserInternal(caller);
ignore EmailSubscribers.add(emailSubscribers, topicId, userProfile.email);
};
public shared ({ caller }) func unsubscribeFromTopic(topicId : Nat) : async () {
let userProfile = getUserInternal(caller);
EmailSubscribers.remove(emailSubscribers, unsubscribeTokens, topicId, userProfile.email);
};
public shared ({ caller }) func sendMarketingEmail(topicId : Nat, subject : Text, htmlBody : Text) : async () {
if (not (AccessControl.hasPermission(accessControlState, caller, #admin))) {
Runtime.trap("Unauthorized: Only admins can send the newsletter");
};
// Ensure the email body contains the unsubscribe link placeholder
let finalHtmlBody = if (htmlBody.contains(#text "{{UNSUBSCRIBE_URL}}")) {
htmlBody;
} else {
htmlBody # "<br><br>To unsubscribe <a href=\"{{UNSUBSCRIBE_URL}}\">click here</a>";
};
// Sent to every verified subscriber of the topic; NAME personalises each copy
let result = await EmailMarketing.sendMarketingEmail(
emailSubscribers,
verifiedEmails,
unsubscribeTokens,
topicId,
"no-reply",
subject,
finalHtmlBody,
func(email) {
switch (userProfiles.values().find(func(user) { user.email == email })) {
case (?user) { [("NAME", user.name)] };
case (null) { [] };
};
},
);
switch (result) {
case (#ok) {};
case (#err(error)) {
Runtime.trap("Failed to send newsletter: " # error);
};
};
};
public query ({ caller }) func listTopics() : async [EmailSubscribers.Topic] {
EmailSubscribers.listTopics(emailSubscribers);
};
// Admin function to list topic subscribers and whether the email is verified or not
public query ({ caller }) func listSubscribers(topicId : Nat) : async [(Text, Bool)] {
if (not (AccessControl.hasPermission(accessControlState, caller, #admin))) {
Runtime.trap("Unauthorized: Only admins can list topic subscribers");
};
EmailSubscribers.list(emailSubscribers, verifiedEmails, topicId).get([]);
};
public query ({ caller }) func isCallerSubscribedToTopic(topicId : Nat) : async Bool {
let userProfile = getUserInternal(caller);
EmailSubscribers.listTopicsForSubscriber(emailSubscribers, userProfile.email).find(
func(topic) { topic.id == topicId }
).isSome();
};
public query ({ caller }) func isCallerEmailVerified() : async Bool {
let userProfile = getUserInternal(caller);
VerifiedEmails.contains(verifiedEmails, userProfile.email);
};
};
迁移链的头部——newsletterTopic 是 transient,因此迁移会重复使用主题字面量,而不是引用它:
import Map "mo:core/Map";
import Set "mo:core/Set";
import AccessControl "mo:caffeineai-authorization/access-control";
import EmailSubscribers "mo:caffeineai-email-marketing/subscribers";
import UnsubscribeTokens "mo:caffeineai-email-marketing/unsubscribeTokens";
import VerificationTokens "mo:caffeineai-email-verification/verificationTokens";
import VerifiedEmails "mo:caffeineai-email-verification/verifiedEmails";
module {
type UserProfile = {
name : Text;
email : Text;
};
type NewActor = {
accessControlState : AccessControl.AccessControlState;
userProfiles : Map.Map<Principal, UserProfile>;
emails : Set.Set<Text>;
verifiedEmails : VerifiedEmails.State;
verificationTokens : VerificationTokens.State;
emailSubscribers : EmailSubscribers.State;
unsubscribeTokens : UnsubscribeTokens.State;
};
public func migration(_old : {}) : NewActor {
{
accessControlState = AccessControl.initState();
userProfiles = Map.empty<Principal, UserProfile>();
emails = Set.empty<Text>();
verifiedEmails = VerifiedEmails.new();
verificationTokens = VerificationTokens.new();
emailSubscribers = EmailSubscribers.new(["Newsletter"]);
unsubscribeTokens = UnsubscribeTokens.new();
};
};
};
从之前的版本升级
使用 caffeineai-email-marketing 0.2.0 构建的应用需要重新固定版本,运行 mops add [email protected]、mops add [email protected] 和 mops add [email protected]。应用代码无需更改:营销邮件现在始终通过邮件网关发送,且该 mixin 会保留 _caffeineEmailUnsubscribeFromTopic 回调,因此传输层已发送邮件中的链接仍可正常工作。
使用 caffeineai-email-marketing 0.1.x 的应用需要完成以下所有步骤(以及 extension-email-verification 的升级步骤),然后执行上述重新固定版本的操作:
- 重新固定版本:运行
mops add [email protected]、mops add [email protected]和mops add [email protected]。 - 声明新的稳定字段
unsubscribeTokens : UnsubscribeTokens.State,并将其添加到迁移链中(新增迁移文件,用UnsubscribeTokens.new()初始化;emailSubscribers的结构和数据保持不变,现有订阅者会在下次发送时获得令牌)。 - 将
include MixinEmailUnsubscribe(emailSubscribers)改为include MixinEmailUnsubscribe(emailSubscribers, unsubscribeTokens)。 - 将
unsubscribeTokens传给EmailSubscribers.remove、EmailSubscribers.removeFromAllTopics和EmailSubscribers.removeTopic:移除订阅者时,也会使已发送给他们的邮件中的退订链接失效。 - 将
EmailClient.sendMarketingEmail(topicId, fromUsername, recipients, subject, htmlBody)替换为EmailMarketing.sendMarketingEmail(emailSubscribers, verifiedEmails, unsubscribeTokens, topicId, fromUsername, subject, htmlBody, personalise)(导入mo:caffeineai-email-marketing/marketing),该方法会自行向主题的已验证订阅者发送邮件;移除收件人列表代码,并将每位收件人的个性化替换内容移到personalise中。若应用需要发送给某个子集,请使用sendMarketingEmailTo,它会向指定地址中属于该主题已验证订阅者的地址发送邮件。 - 添加前端包和下文所述的
/unsubscribe路由。
前端
安装 @caffeineai/email-marketing。该包导出预制的钩子 useUnsubscribe,且无法修改:
export interface UnsubscribeActor {
// the bindgen wrapper turns Candid's optional text into an optional field
_caffeineEmailUnsubscribe(token: string): Promise<{ ok: { topic?: string | null } } | { err: string }>;
}
// ready: a token is in the URL and nothing was sent yet
// pending: the backend call is in flight
// done: the address is unsubscribed; `topic` names the topic when it still exists
// invalid: the URL has no token, or the backend rejected it
// error: the backend call failed; `error` holds the message
export type UnsubscribeStatus = 'ready' | 'pending' | 'done' | 'invalid' | 'error';
export declare function useUnsubscribe(actor: UnsubscribeActor | null | undefined): {
status: UnsubscribeStatus;
topic: string | null;
error: string | null;
hasToken: boolean;
// True once the actor is available, a token is present and nothing was sent yet
isReady: boolean;
// Unsubscribes through the backend. Call it from a button click only.
confirm: () => Promise<void>;
};
退订页面规则:
- 添加
/unsubscribe路由,使其无需登录即可访问:收件人从邮件客户端打开该页面时没有会话。后端方法仅将令牌作为凭据,因此使用useActor获取的匿名 actor 即可。 - 使用该钩子读取令牌(它会自行从 URL 中获取
?token=),并渲染一个调用confirm()的按钮。切勿在页面加载时或在 effect 中调用confirm():邮件客户端、链接预览器和扫描器会在没有用户操作的情况下获取 URL,而此类获取不能退订任何人。 - 根据
status显示结果:done时显示主题名称(如果存在),invalid表示链接缺失或已使用,error显示错误消息。
页面示例:
import { useUnsubscribe } from '@caffeineai/email-marketing';
import { useActor } from '@caffeineai/core-infrastructure';
import { createActor } from '@/backend';
export function UnsubscribePage() {
const { actor } = useActor(createActor);
const { status, topic, error, isReady, confirm } = useUnsubscribe(actor);
if (status === 'done') return <p>You are unsubscribed{topic ? ` from ${topic}` : ''}.</p>;
if (status === 'invalid') return <p>{error ?? 'This unsubscribe link is invalid or was already used.'}</p>;
if (status === 'error') return <p>Something went wrong: {error}</p>;
return (
<button onClick={() => void confirm()} disabled={!isReady}>
{status === 'pending' ? 'Unsubscribing…' : 'Unsubscribe'}
</button>
);
}
在应用的 TanStack Router 中,与其他路由一同注册该路由,作为根路由的子路由,设置 path: '/unsubscribe',并置于任何经过身份验证的布局之外。
如果有供管理员输入营销邮件内容的界面,请提示邮件正文中应包含占位文本 {{UNSUBSCRIBE_URL}}。