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を入力して読み込まれたか確認します。
詳細
メール — マーケティング
Caffeine AI 用のマーケティングメール拡張機能です。
概要
このスキルでは、購読者の管理、トピック別の購読、アプリ独自のフロントエンドに遷移する配信停止リンクを備えた、直接的なマーケティングメール送信に対応します。バックエンドは購読者とトピックごとに配信停止トークン(unsubscribeTokens)を発行し、MixinEmailUnsubscribe で処理します。フロントエンドでは /unsubscribe ページを表示し、受信者がボタンをクリックして配信を停止します。ユーザーがマーケティングメールを受信するには、メールアドレスの検証が必要です。
必須セットアップチェックリスト
6 つの手順すべてが必須です。
- 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をインストールし、サインインせずにアクセスでき、confirm()を呼び出すボタンを表示する公開ルート/unsubscribeを用意します。
重要:配信停止リンクは 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 を使用します。このモジュールは購読者とトピックごとに 1 つのトークンを保持します。トークンは初回送信時に発行され、古いメールのリンクも引き続き機能するよう保持されます。受信者がリンクを使用すると、そのトークンは削除されます。アプリ側で行うのは、状態を宣言して受け渡すことだけです。
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 コールバックも維持するため、トランスポートカナスターが 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になります。- 結果を返します。メールが正常に送信された場合は #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] を実行して再度ピン留めします。アプリのコードに変更はありません。マーケティングメールは常にメールゲートウェイ経由で送信されるようになり、ミックスインは _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)に変更します。EmailSubscribers.remove、EmailSubscribers.removeFromAllTopics、EmailSubscribers.removeTopicにunsubscribeTokensを渡します。購読者を削除すると、すでにその人に送信したメールの購読解除リンクも無効になります。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の匿名アクターで十分です。 - フックを使ってトークンを読み取り(URL から
?token=を取得します)、confirm()を呼び出すボタンを表示します。ページの読み込み時やエフェクト内でconfirm()を呼び出してはいけません。メールクライアント、リンクプレビューアー、スキャナーはユーザーが操作していなくても URL を取得するため、取得だけで誰かの購読が解除されてはなりません。 statusの結果を表示します。doneの場合はtopicがあればその名前を表示し、リンクがない場合や使用済みの場合は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' を設定し、認証が必要なレイアウトの外側に配置します。
管理者がマーケティングメールの本文を入力する UI がある場合は、メール本文にプレースホルダーテキスト {{UNSUBSCRIBE_URL}} を含める必要があることを示してください。