en un clic
extension-email-marketing
// Send personalised marketing emails to subscribers with an unsubscribe link.
// Send personalised marketing emails to subscribers with an unsubscribe link.
Authorization system with role-based access control. Must-have for all apps that manage personal or access-restricted data.
MANDATORY recipe for every Caffeine build that posts to X (Twitter). The ONLY supported path is the `x-client` mops package with OAuth 2.0 PKCE. Hand-rolling `ic.http_request` or `icBooking.http_request` calls to `api.x.com/2/tweets`, `api.x.com/2/oauth2/token`, or any other X endpoint is a FORBIDDEN anti-pattern — it bypasses bearer auth, replication-cost safeguards, and `x-client`'s null-field handling. Load this skill whenever the user, spec, or any prior task mentions tweeting, live-tweeting, posting-to-X, posting-a-status, sharing-to-Twitter, or any equivalent phrasing — and BEFORE writing any code that touches `api.x.com`.
MANDATORY recipe for every Caffeine build that calls OpenAI (ChatGPT, GPT-4o, an LLM, a chatbot, embeddings). The ONLY supported path is the `openai-client` mops package with a canister-side API-key bearer. Hand-rolling `ic.http_request` to `api.openai.com/v1/...` is a FORBIDDEN anti-pattern — it leaks the bearer across replicated outcalls (security + 13× billing impact), bypasses the typed request/response bindings, and forces hand-rolled JSON on a language with poor JSON support. Load this skill whenever the user, spec, or any prior task mentions ChatGPT, GPT (any version), OpenAI, an LLM, a chatbot, or embeddings — and BEFORE writing any code that touches `api.openai.com`.
Core infrastructure providing backend connection configuration, storage client, and React app entry point.
Web-camera support.
Support for organising events/meetings and sending invitations by email.
| name | extension-email-marketing |
| description | Send personalised marketing emails to subscribers with an unsubscribe link. |
| version | 0.1.5 |
| compatibility | {"mops":{"caffeineai-email-marketing":"~0.1.1","caffeineai-authorization":"~0.1.1","caffeineai-email-verification":"~0.1.1"}} |
| caffeineai-subscription | ["plus","pro"] |
Marketing email extension for Caffeine AI.
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.
mo:caffeineai-email-marketing/subscribers.mo which cannot be modified.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;
};
Use the prefabricated module caffeineai-email-marketing/unsubscribeMixin.mo which cannot be modified.
The MixinEmailUnsubscribe module handles calls to the unsubscribe link to unsubscribe an email address from a topic.
import MixinEmailUnsubscribe "mo:caffeineai-email-marketing/unsubscribeMixin";
email component for sending email addresses.recipients argument is an array of email addresses, where for each email an optional array of substitution name/value pairs can be specified.
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;
};
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);
};
};