extension-email-marketing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseEmail — Marketing
邮件 — 营销
Marketing email extension for Caffeine AI.
适用于Caffeine AI的营销邮件扩展。
Overview
概述
This skill adds direct marketing email support with subscriber management, topic-based subscriptions, and automatic unsubscribe links. Requires email verification before users can receive marketing emails.
该Skill为系统添加了直接营销邮件支持,包含订阅者管理、基于主题的订阅以及自动退订链接功能。用户需要先完成邮箱验证才能接收营销邮件。
Backend
后端
This component is for sending direct marketing emails and managing subscribers to marketing topics.
该组件用于发送直接营销邮件并管理营销主题的订阅者。
- Users MUST have verified their email address AND MUST be subscribed to a marketing topic before they can receive marketing emails on that topic
- Marketing emails MUST contain an unsubscribe link which will unsubscribe the user from the given topic
- This component depends on the extension-email-verification for verifying email addresses, be sure to check that too.
- 用户必须已验证邮箱地址,并且已订阅某一营销主题,才能接收该主题的营销邮件
- 营销邮件必须包含退订链接,点击该链接可让用户退订对应主题
- 该组件依赖extension-email-verification来完成邮箱验证,请务必查看该组件的文档。
To subscribe users to marketing topics and manage lists of subscribers
订阅用户到营销主题并管理订阅者列表
- Use the prefabricated module which cannot be modified.
mo:caffeineai-email-marketing/subscribers.mo - Marketing email subscribers MUST be handled solely through this subscribers module
- Do NOT also store a subscribed status against user profiles
mo
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.
public func removeTopic(state : 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
public func remove(state : State, topicId : Nat, email : Text);
// Remove a subscriber from all topics
public func removeFromAllTopics(state : 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/subscribers.mo - 营销邮件订阅者必须仅通过该subscribers模块进行处理
- 请勿在用户档案中额外存储订阅状态
mo
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.
public func removeTopic(state : 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
public func remove(state : State, topicId : Nat, email : Text);
// Remove a subscriber from all topics
public func removeFromAllTopics(state : 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;
};To handle the unsubscribe link
处理退订链接
Use the prefabricated module which cannot be modified.
caffeineai-email-marketing/unsubscribeMixin.moThe MixinEmailUnsubscribe module handles calls to the unsubscribe link to unsubscribe an email address from a topic.
mo
import MixinEmailUnsubscribe "mo:caffeineai-email-marketing/unsubscribeMixin";使用预制模块,该模块不可修改。
caffeineai-email-marketing/unsubscribeMixin.moMixinEmailUnsubscribe模块负责处理退订链接的调用,实现邮箱地址从对应主题的退订操作。
mo
import MixinEmailUnsubscribe "mo:caffeineai-email-marketing/unsubscribeMixin";For sending direct marketing emails from the backend
从后端发送直接营销邮件
- This component depends on the component for sending email addresses.
email - Use the sendMarketingEmail function.
- This MUST be used alongside the subscribers.mo module and unsubscribeMixin.mo module
- The argument is an array of email addresses, where for each email an optional array of substitution name/value pairs can be specified.
recipients- These substitutions allow the email to be personalised for each recipient.
- If the email body contains the substitution name in double curly braces it is replaced by the substitution value in the email to that recipient.
- It returns a Result which is #ok if the email is sent successfully otherwise #err(error) with the error text.
- Ensure the placeholder text {{UNSUBSCRIBE_URL}} is appended to the htmlBody if not already present. This will be replaced automatically by the system with a specific unsubscribe url for each recipient.
mo
module {
public type BroadcastEmailRecipient = {
email : Text;
substitutions : ?[(Text, Text)];
};
public type SendResult = {
#ok;
#err : Text;
};
public func sendMarketingEmail(
topicId : Nat,
fromUsername : Text,
recipients : [BroadcastEmailRecipient],
subject : Text,
htmlBody : Text,
) : async SendResult;
};- 该组件依赖组件来发送邮件。
email - 使用sendMarketingEmail函数。
- 必须与subscribers.mo模块和unsubscribeMixin.mo模块配合使用
- 参数是邮箱地址数组,每个邮箱可指定一个可选的替换名/值对数组。
recipients- 这些替换项可实现为每个收件人个性化邮件内容。
- 如果邮件正文中包含用双大括号包裹的替换名,系统会将其替换为对应收件人的替换值。
- 该函数返回一个Result类型,邮件发送成功时返回#ok,失败时返回#err(error)并附带错误文本。
- 请确保占位文本{{UNSUBSCRIBE_URL}}已添加到htmlBody中(如果尚未存在)。系统会自动将其替换为每个收件人的专属退订链接。
mo
module {
public type BroadcastEmailRecipient = {
email : Text;
substitutions : ?[(Text, Text)];
};
public type SendResult = {
#ok;
#err : Text;
};
public func sendMarketingEmail(
topicId : Nat,
fromUsername : Text,
recipients : [BroadcastEmailRecipient],
subject : Text,
htmlBody : Text,
) : async SendResult;
};Example usage for an app which can send marketing emails to users who are subscribed to topics managed by the admin
示例用法:向管理员管理的主题订阅用户发送营销邮件的应用
motoko
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 EmailClient "mo:caffeineai-email/emailClient";
import MixinEmailUnsubscribe "mo:caffeineai-email-marketing/unsubscribeMixin";
import EmailSubscribers "mo:caffeineai-email-marketing/subscribers";
import MixinEmailVerification "mo:caffeineai-email-verification/verificationMixin";
import VerifiedEmails "mo:caffeineai-email-verification/verifiedEmails";
actor {
public type UserProfile = {
name : Text;
email : Text;
};
// Include authorization component
let accessControlState = AccessControl.initState();
include MixinAuthorization(accessControlState);
// Store a map of caller principal to UserProfile
let userProfiles = Map.empty<Principal, UserProfile>();
// Store a set of emails for uniqueness check
let emails = Set.empty<Text>();
// Stores which emails are verified
let verifiedEmails = VerifiedEmails.new();
// In this example we use a single hardcoded topic.
// In general there could be CRUD endpoints for the admin to manage email subscription topics.
let newsletterTopic = "Newsletter";
// Store the email subscribers per topic
let emailSubscribers = EmailSubscribers.new([newsletterTopic]);
// Include this mixin to handle the unsubscribe link which updates the EmailSubscribers state
include MixinEmailUnsubscribe(emailSubscribers);
// Include this mixin to handle the verification link which updates the VerifiedEmails state
include MixinEmailVerification(verifiedEmails);
func getUserInternal(caller : Principal) : UserProfile {
switch (userProfiles.get(caller)) {
case (null) { Runtime.trap("User profile does not exist!") };
case (?userProfile) { userProfile };
};
};
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
switch (EmailSubscribers.getTopicId(emailSubscribers, newsletterTopic)) {
case (null) { Runtime.trap("Newsletter topic not found") };
case (?topicId) {
ignore EmailSubscribers.add(emailSubscribers, topicId, email);
};
};
// Send a verification email
let result = await EmailClient.sendVerificationEmail(
"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, 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, 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");
};
// Get the array of subscriber emails that have been verified
let recipientEmails = switch (EmailSubscribers.verified(emailSubscribers, verifiedEmails, topicId)) {
case (null) {
Runtime.trap("No verified subscribers found for newsletter topic");
};
case (?recipientEmails) { recipientEmails };
};
if (recipientEmails.size() == 0) {
Runtime.trap("No verified subscribers found for newsletter topic");
};
// 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>";
};
// For each recipient specify the NAME substitution to personalise the email
let recipients = recipientEmails.filterMap(
func(email) {
switch (userProfiles.values().find(func(user) { user.email == email })) {
case (?user) { ?{ email; substitutions = ?[("NAME", user.name)] } };
case (null) { null };
};
}
);
let result = await EmailClient.sendMarketingEmail(
topicId,
"no-reply",
recipients,
subject,
finalHtmlBody,
);
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);
};
};motoko
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 EmailClient "mo:caffeineai-email/emailClient";
import MixinEmailUnsubscribe "mo:caffeineai-email-marketing/unsubscribeMixin";
import EmailSubscribers "mo:caffeineai-email-marketing/subscribers";
import MixinEmailVerification "mo:caffeineai-email-verification/verificationMixin";
import VerifiedEmails "mo:caffeineai-email-verification/verifiedEmails";
actor {
public type UserProfile = {
name : Text;
email : Text;
};
// Include authorization component
let accessControlState = AccessControl.initState();
include MixinAuthorization(accessControlState);
// Store a map of caller principal to UserProfile
let userProfiles = Map.empty<Principal, UserProfile>();
// Store a set of emails for uniqueness check
let emails = Set.empty<Text>();
// Stores which emails are verified
let verifiedEmails = VerifiedEmails.new();
// In this example we use a single hardcoded topic.
// In general there could be CRUD endpoints for the admin to manage email subscription topics.
let newsletterTopic = "Newsletter";
// Store the email subscribers per topic
let emailSubscribers = EmailSubscribers.new([newsletterTopic]);
// Include this mixin to handle the unsubscribe link which updates the EmailSubscribers state
include MixinEmailUnsubscribe(emailSubscribers);
// Include this mixin to handle the verification link which updates the VerifiedEmails state
include MixinEmailVerification(verifiedEmails);
func getUserInternal(caller : Principal) : UserProfile {
switch (userProfiles.get(caller)) {
case (null) { Runtime.trap("User profile does not exist!") };
case (?userProfile) { userProfile };
};
};
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
switch (EmailSubscribers.getTopicId(emailSubscribers, newsletterTopic)) {
case (null) { Runtime.trap("Newsletter topic not found") };
case (?topicId) {
ignore EmailSubscribers.add(emailSubscribers, topicId, email);
};
};
// Send a verification email
let result = await EmailClient.sendVerificationEmail(
"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, 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, 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");
};
// Get the array of subscriber emails that have been verified
let recipientEmails = switch (EmailSubscribers.verified(emailSubscribers, verifiedEmails, topicId)) {
case (null) {
Runtime.trap("No verified subscribers found for newsletter topic");
};
case (?recipientEmails) { recipientEmails };
};
if (recipientEmails.size() == 0) {
Runtime.trap("No verified subscribers found for newsletter topic");
};
// 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>";
};
// For each recipient specify the NAME substitution to personalise the email
let recipients = recipientEmails.filterMap(
func(email) {
switch (userProfiles.values().find(func(user) { user.email == email })) {
case (?user) { ?{ email; substitutions = ?[("NAME", user.name)] } };
case (null) { null };
};
}
);
let result = await EmailClient.sendMarketingEmail(
topicId,
"no-reply",
recipients,
subject,
finalHtmlBody,
);
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);
};
};