mcpField
E
extension-email-marketing

extension-email-marketing

github.com/caffeinelabs营销与增长228.6k 次安装6 天前更新
代码仓库

关于 extension-email-marketing

此 Caffeine AI 扩展为应用提供营销邮件发送、主题订阅管理和退订流程,适合需要向订阅者发送定向邮件的应用。发送对象限于已验证邮箱且订阅相应主题的用户,并提供应用内退订链接。

主要功能

  1. 01管理主题及订阅者
  2. 02仅向已验证订阅者发送
  3. 03按收件人个性化邮件
  4. 04生成主题对应的退订链接
  5. 05提供公开退订页面

适用场景

  1. 01向特定主题订阅者发送邮件
  2. 02维护营销邮件订阅名单
  3. 03让收件人自行退订主题

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/caffeinelabs/skills --skill extension-email-marketing

安装后在 Claude Code 中输入/extension-email-marketing确认已加载。

详细介绍

Email — 营销

适用于 Caffeine AI 的营销邮件扩展。

概述

此技能添加了直接营销邮件支持,包括订阅者管理、基于主题的订阅,以及指向应用自身前端的退订链接。后端为每位订阅者和主题生成一个退订令牌(unsubscribeTokens),并通过 MixinEmailUnsubscribe 处理;前端渲染 /unsubscribe 页面,收件人可在其中点击按钮退订。用户必须先验证邮箱,才能接收营销邮件。

必需的设置清单

以下六个步骤均为必需。

  1. mops 依赖项 — mops add caffeineai-email-marketing、mops add caffeineai-email-verification 和 mops add caffeineai-authorization。
  2. 稳定状态字段 — emailSubscribers : EmailSubscribers.State 和 unsubscribeTokens : UnsubscribeTokens.State(以及验证状态 verifiedEmails 和 verificationTokens),并在迁移链头部初始化。
  3. Mixin 调用 — 在 main.mo 中加入 include MixinEmailUnsubscribe(emailSubscribers, unsubscribeTokens) 和 include MixinEmailVerification(verifiedEmails, verificationTokens)。
  4. 发送 — EmailMarketing.sendMarketingEmail(emailSubscribers, verifiedEmails, unsubscribeTokens, topicId, fromUsername, subject, htmlBody, personalise) 会向该主题的所有已验证订阅者发送邮件;应用绝不会自行组装收件人列表。sendMarketingEmailTo 接收明确指定的地址,并只向其中属于该主题已验证订阅者的地址发送邮件。
  5. 退订链接 — 正文应包含 {{UNSUBSCRIBE_URL}};如果缺少此占位符,系统会追加一行退订说明。每封邮件还会附带一个指向同一页面的 List-Unsubscribe 标头。
  6. 前端 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 的升级步骤),然后执行上述重新固定版本的操作:

  1. 重新固定版本:运行 mops add [email protected]、mops add [email protected] 和 mops add [email protected]。
  2. 声明新的稳定字段 unsubscribeTokens : UnsubscribeTokens.State,并将其添加到迁移链中(新增迁移文件,用 UnsubscribeTokens.new() 初始化;emailSubscribers 的结构和数据保持不变,现有订阅者会在下次发送时获得令牌)。
  3. 将 include MixinEmailUnsubscribe(emailSubscribers) 改为 include MixinEmailUnsubscribe(emailSubscribers, unsubscribeTokens)。
  4. 将 unsubscribeTokens 传给 EmailSubscribers.remove、EmailSubscribers.removeFromAllTopics 和 EmailSubscribers.removeTopic:移除订阅者时,也会使已发送给他们的邮件中的退订链接失效。
  5. 将 EmailClient.sendMarketingEmail(topicId, fromUsername, recipients, subject, htmlBody) 替换为 EmailMarketing.sendMarketingEmail(emailSubscribers, verifiedEmails, unsubscribeTokens, topicId, fromUsername, subject, htmlBody, personalise)(导入 mo:caffeineai-email-marketing/marketing),该方法会自行向主题的已验证订阅者发送邮件;移除收件人列表代码,并将每位收件人的个性化替换内容移到 personalise 中。若应用需要发送给某个子集,请使用 sendMarketingEmailTo,它会向指定地址中属于该主题已验证订阅者的地址发送邮件。
  6. 添加前端包和下文所述的 /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}}。

评论

支持 Markdown