portaly-email について
Portaly EmailのトランザクションメールAPIをアプリに組み込むクリエイター向けのスキルです。アカウントとドメインの設定、サーバー側送信、再試行、配信状況の追跡を扱い、ベータ版は招待制です。
主な機能
- 01APIキーとドメインの設定
- 02サーバー側でのメール送信
- 03冪等キーを使った再試行
- 04予約・一括メール送信
- 05配信状況の定期的な追跡
活用シーン
- 01注文の領収書や通知の送信
- 02認証メールやパスワード再設定メールの配信
- 03バウンスや苦情のあった宛先の確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ portaly-ai/ portaly-skills --skill portaly-emailインストール後、Claude Code で/portaly-emailを入力して読み込まれたか確認します。
同梱ファイルreferences/api-contract.md
詳細
Portaly Email(ベータ版)
このスキルを使って、クリエイター(通常はエンジニアではありません)などのユーザーが、自分のアプリから送るメール(領収書、確認コード、パスワードのリセット、通知)を Portaly のメール API に接続できるよう支援します。 回答は実務的な内容にし、まず次の手順を示してから、プロジェクトで使われているスタックに合わせた、すぐにコピーできるコードを提示してください。
これはトランザクションメール専用です。1 人のユーザーの操作をきっかけに送信するメールを指します。
ニュースレターや読者向けのお知らせは、この API ではなく Portaly の管理画面(https://portaly.cc/admin/email)から送信します。
ベータ版 — 招待制です。 Portaly Email はベータ版のため、Portaly から招待されたアカウントのみ利用できます。 それ以外のアカウントはすべての呼び出しで
403 FORBIDDENとなり、エージェントにはユーザーに Portaly へ問い合わせるよう案内する以外にできることはありません。メール API キーの作成には、月間クォータが付与される Premium プランも必要です。ユーザーが招待について言及していない場合は、セットアップ作業を始める前にこの点を伝えてください。
API ホスト
const PORTALY_API_HOST = process.env.PORTALY_API_HOST || 'https://portaly.ai'
ユーザーが意図的に本番環境以外の Portaly を指定している場合を除き、PORTALY_API_HOST は未設定のままにしてください。正式な仕様は https://portaly.ai/docs のメールセクションと https://portaly.ai/openapi.json で確認できます。ここにある references/api-contract.md は作業用のコピーです。内容が異なる場合は、公式ドキュメントを優先してください。
クイックスタート
- キー — ユーザーが Portaly の管理画面でメール API キーを作成し、
.envにPORTALY_EMAIL_API_KEYとして設定します。(ワークフローのステップ 1) - まずはサンドボックス — ドメインを認証するまでは、
<name>@sandbox.portaly.twから送信します。送信先はアカウント所有者の認証済みサインイン用メールアドレスのみで、1 日 50 通までです。<name>は 3〜32 文字の英字、数字、.、_、-で、portalyを含まず、noreply、no-reply、support、info、adminなどの予約名であってはなりません(400 INVALID_SENDER)。例:orders、hello。コードが動作することを確認するには十分です。 - ドメイン — ユーザーが送信元サブドメインを登録して DNS レコードを追加し、キーがそのドメインを対象にしていることを確認します。(ワークフローのステップ 2)
- 送信 — サーバー側のコードから
Idempotency-Keyを指定して送信します。(ワークフローのステップ 3) - レスポンスの処理と配信状況の追跡 — 再試行可能なものは再試行し、クォータ超過時にはアラートを出し、バウンスしたアドレスや苦情が寄せられたアドレスを記録します。(ワークフローのステップ 4〜5)
ワークフロー
1. API キーを取得する(ユーザーの作業)
-
ユーザーは
https://portaly.cc/admin/email/api-keys(經營工具 › 信件管理 › API 管理)でキーを作成します。 キーはpem_で始まり、一度しか表示されません。 -
権限:送信のみを行うアプリには
sending(送信、一覧表示、検索、キャンセル、クォータの確認)で十分です。API 経由でドメインも登録する場合にのみfullを選択してください。 -
キーで送信できるのは、作成時に選択したドメインからのみです。ドメインがまだ認証されていない場合、管理画面のデフォルトは
sandbox.portaly.twのみです。そのため、今作成したキーでは、後から実ドメインを使って送信できません(403 DOMAIN_NOT_ALLOWED)。「全部網域」(後から登録するドメインも含むすべてのドメイン。fullキーでは常にこの設定になります)を選択するか、ステップ 2 の完了後にユーザーに別のキーを作成してもらってください。sandbox.portaly.twのみに制限されたキーは実質的にテスト用のキーです。独立したテストモードはありません。 -
これは Portaly Payment のキーではありません。
pcs_live_/pcs_test_キーではメールを送信できず、pem_キーでは決済エンドポイントを呼び出せません。両方を使うプロジェクトでは、両方のキーを保持してください。 -
ユーザーにキーをチャットに貼り付けるよう求めてはいけません。 ユーザー自身で
.envに追加するよう案内してください。PORTALY_EMAIL_API_KEY=pem_xxxprocess.env.PORTALY_EMAIL_API_KEYで読み取ります。何よりも先に.envが.gitignoreに含まれていることを確認してください。キーがチャットに貼り付けられた場合は、管理画面でそのキーを無効化し、新しいキーを作成するよう伝えてください。 -
サーバー側のみで使用してください。 キーをブラウザーのコード、
NEXT_PUBLIC_/VITE_変数、モバイルアプリ、またはユーザーに配布されるものに含めてはいけません。キーを持つ人は誰でも、クリエイターのドメインからメールを送信できます。
2. 送信ドメインを選択して確認する
- メールの送信元にするアドレス(
orders@…、no-reply@…)を確認します。メインの受信トレイにバウンスの問題が影響しないよう、メールを受信しないサブドメイン(例:mail.example.com)を推奨します。 fullキーがある場合は、GET /api/email/domainsでバインド済み・検証済みのドメインを確認できます。キーがない場合は、担当者にhttps://portaly.cc/admin/email/domains(信箱設定)を確認してもらいます。- バインドすると(管理画面から、または
fullキーを使って{ "host": "mail.example.com" }を指定したPOST /api/email/domainsを実行すると)、dnsRecordsが返されます。**MAIL FROM サブドメイン用の DKIMCNAME3 件に加え、MXとTXTが 1 件ずつ含まれます。**担当者が DNS プロバイダーですべてのレコードを追加し、管理画面で「重新檢查」を押します。DNS の反映には数分から数時間かかる場合があります。identityStatus: "verified"とmailFromStatus: "success"になれば準備完了です。メインドメインの既存のレコード(MX、SPF、DKIM)は変更しないでください。Cloudflare では、新しいレコードをプロキシ経由ではなく、DNS のみ(灰色の雲)に設定してください。 - 送信サブドメインではメールを受信しないため、
replyToにはメールを受信できるアドレス(例:[email protected])を設定します。設定しないと、顧客からの返信が届きません。 - それまではサンドボックスを使ってビルドとテストを行います。
sandbox.portaly.twのsender.emailを使用し、受信者にはアカウント所有者の検証済みサインイン用メールアドレスを指定します。所有者が検証済みメールアドレスを使わずにサインインしている場合(例:電話番号や LINE を使用した場合)、サンドボックスからはメールをまったく配信できません(403 RECIPIENT_NOT_ALLOWED)。後で実際のドメインに切り替える際は、コードではなく設定を変更します。 - ドメインの検証が完了したら、キーがそのドメインを対象としていることを確認します(手順 1)。対象でない場合、送信時に
403 DOMAIN_NOT_ALLOWEDが返されます。 mail.portaly.twなどの Portaly ドメインからは絶対に送信しないでください。403 DOMAIN_NOT_ALLOWEDが返されます。
3. 送信する
サーバー側コードからのみ、POST {PORTALY_API_HOST}/api/email/emails を実行します。
const PORTALY_API_HOST = process.env.PORTALY_API_HOST || 'https://portaly.ai'
export class PortalyEmailError extends Error {
readonly status: number
readonly code: string | undefined
readonly retryAfterSeconds: number | null
constructor(status: number, code: string | undefined, message: string | undefined, retryAfterSeconds: number | null) {
super(message ?? `Portaly email API answered ${status}`)
this.name = 'PortalyEmailError'
this.status = status
this.code = code
this.retryAfterSeconds = retryAfterSeconds
}
}
export async function sendEmail(input: {
to: string
subject: string
html: string
idempotencyKey: string // derive from your own event, e.g. `order-${orderId}-receipt`; letters, digits, `_`, `-`
}) {
const res = await fetch(`${PORTALY_API_HOST}/api/email/emails`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PORTALY_EMAIL_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': input.idempotencyKey,
},
body: JSON.stringify({
sender: { email: process.env.EMAIL_FROM, name: 'Example Shop' },
replyTo: process.env.EMAIL_REPLY_TO, // the sending subdomain does not receive mail
recipients: [{ email: input.to }],
subject: input.subject,
html: input.html,
tags: [{ name: 'ref', value: input.idempotencyKey }], // to find this email again (step 4, 409)
}),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
const retryAfter = res.headers.get('retry-after')
throw new PortalyEmailError(res.status, body.error?.code, body.error?.message, retryAfter ? Number(retryAfter) : null)
}
return body.data.id as string // emsg_…, store it next to your own record
}
- 必ずビジネスイベントに基づく
Idempotency-Keyを指定します。試行ごとにランダムな値を使ってはいけません。同じキーで再試行しても二重送信されないため、タイムアウト後も安全に再試行できます。 - **送信するメールごとにキーを 1 つ指定し、ユーザーごとに 1 つ指定することは避けます。**キーの有効期限はなく、Portaly は本文を比較しません。同じキーを再利用すると、最初のメールの ID が返され、メールは送信されません。
order-${orderId}-receiptは適切ですが、password-reset-${userId}は最初のリセット後、以降のリセットを通知なしに破棄します。リセット要求ごとに固有の ID を使用してください。内容が変わる場合は、新しいキーを使います。 - キーは Portaly アカウントと送信ドメインの範囲で適用されます。ステージング環境と本番環境で同じアカウントとドメインから送信する場合は、キーに環境名のプレフィックスを付けます(
prod-order-…など)。そうしないと、環境間で ID が重複した場合に、本番環境でステージング環境のメールが通知なしに再利用されます。 - すべてのメールにそのキーをタグとして付けます(前述の
{ name: 'ref', value: <key> })。一覧の結果にはIdempotency-Keyが含まれないため、特定のメールを再度検索するにはタグが必要です。タグの値に使えるのは英字、数字、_、-のみです。これらの文字でキーを作成してください。 EMAIL_FROMとEMAIL_REPLY_TOは設定に保存しておきます。そうすれば、サンドボックスから検証済みドメインへの切り替えは設定変更だけで済みます。- 返された
idは、対応する注文またはユーザーとともに保存します。後で配信状況を調べる際に必要です。 - 受信者は、メール 1 件あたり最大 50 人まで指定でき、
{ email, type }の形式で記述します。typeにはto、cc、bccを指定でき、省略時はtoです。これは1 通のメッセージであり、CC の受信者同士は互いのアドレスが見えます。複数の相手に個別のメッセージを送る場合は、POST /api/email/batchesを使用します(最大 100 件のメールをバックグラウンドで送信)。 scheduledAt(タイムゾーン付き ISO 8601 形式、1 分後から 30 日未満先まで。上限は 30 日から 10 分を引いた日時)を指定すると予約送信になり、202が返されます。POST /api/email/emails/{id}/cancelを実行すると、送信前にキャンセルできます。- 添付ファイルには、
content(base64 形式。ファイルサイズは約 3 MB まで。リクエストの上限は 4.5 MB)またはurl(Portaly がダウンロードする公開 HTTPS URL)を指定できます。メール 1 件あたりの合計サイズは 10 MB までです。 - 各フィールドのルールとバッチの形式については、
references/api-contract.mdを参照してください。
4. レスポンスを処理する
すべてのエラーは { "error": { "code", "message" } } です。message ではなく、code に基づいて分岐してください。
| レスポンス | コードで行うこと |
|---|---|
200 / 202 | data.id を保存します。 |
429 RATE_LIMITED | キーごとのレート制限です。Retry-After 秒待って、同じ Idempotency-Key で再試行してください。 |
500、任意の 503、ネットワークエラーまたはタイムアウト | 同じ Idempotency-Key で、上限(例:5回)を設けて指数バックオフで再試行します。その後、後続のジョブ用にキューに登録してアラートを出します。二重送信にはなりません。Retry-After の単位は秒です。 |
502 SEND_OUTCOME_UNKNOWN | 送信されている可能性があります。同じ Idempotency-Key で再試行してください。Portaly が結果を把握するまでは、その再試行に 409 が返されます。 |
409 IDEMPOTENCY_KEY_IN_USE | 同じキーで約10分間まで再試行します。上限を設け、決して無限にループさせないでください。 その後も 409 が続く場合、送信されていない可能性が高いです(そのキーは以後ずっと 409 のままです)。GET /api/email/emails?status=unknown&tag=ref:<key> で探し、人にアラートを出すか、新しいキーで再送信します。その際、重複送信のリスクが多少あることを受け入れてください。 |
429 SEND_QUOTA_EXCEEDED | 一時的なエラーではありません。ループで再試行しないでください。 人にアラートを出します(エラーレベルでログに記録し、通知します)。作成者がクォータを追加購入するか、月次リセットまで待ちます。 |
429 SANDBOX_DAILY_LIMIT_REACHED | サンドボックスメールは1日50通までです。Asia/Taipei の午前0時にリセットされます。 |
422 RECIPIENT_SUPPRESSED | すべての to アドレスが Portaly の抑制リストに登録されています(ハードバウンスが発生したか、複数の Portaly 送信者からそのアドレスについて迷惑メールの苦情が寄せられています)。再試行しないでください。DB で無効としてマークし、サインイン済みユーザーには新しいアドレスを求めてください。サインアウト状態のフロー(パスワードリセット)では、アカウントの有無が判明しないよう、通常の汎用メッセージを表示してください。 |
422 MESSAGE_REJECTED | メールサービスがこのメッセージを拒否しました。同じキーでは同じエラーが返され続けるため、修正して新しいキーで送信してください。 |
403 DOMAIN_NOT_ALLOWED | そのドメインはこのアカウントに紐付けられていない、Portaly のドメインである、またはキーの対象範囲に含まれていません(ステップ1)。作成者に表示し、再試行しないでください。 |
その他の 403 DOMAIN_* | セットアップの問題です。ステップ2を参照してください。作成者に表示し、再試行しないでください。 |
403 HARD_BOUNCE_LIMIT_REACHED | そのドメインは本日のハードバウンス上限に達しており、通常はその後停止されるまで、残りの時間はブロックされます。そのドメインからの送信を停止し、人にアラートを出して、bounced アドレスを整理してください(ステップ5)。本日は再試行しないでください。 |
403 RECIPIENT_NOT_ALLOWED | サンドボックスからアカウント所有者以外に送信しようとしています。 |
その他の 4xx | リクエストを修正してください。INVALID_BODY のメッセージは、不正なフィールド(recipients.0.email: …)から始まります。 |
- クォータ上限に達しないよう、大量送信の前、または定期的に
GET /api/email/quotaのdata.remainingを確認し、残りが少なくなったら作成者に警告してください。クォータは受信者ごとに消費され、管理画面から送信するブロードキャストと共有されます。サンドボックスメールではクォータは消費されません。
5. 配信状況を追跡する
Webhook はまだ利用できません。10~15 分ごとなどの定期ジョブを実行し、GET /api/email/emails?recipientStatus=bounced,complained&startDate=… で最近の問題を一覧表示し、startAfter=<pagination.nextCursor> を使ってページングしてください。startDate はメールの作成日時で絞り込みます。迷惑メール報告は数日後に届くこともあるため、実行ごとに約 7 日前までさかのぼって確認してください。アプリで scheduledAt やバッチを使っている場合は、recipientStatus=failed,quota_exceeded,domain_limited,suppressed も一覧表示してください。これらの状態は配信予定時刻になると確認され、ブロックされたメールはここにのみ表示されます。
GET /api/email/emails/{id} では、1 通のメールの受信者を確認できます。少数のメールなら問題ありませんが、一覧表示やクォータ確認と合わせて、読み取りリクエストはキーごとに 1 分あたり 120 件を共有します。そのため、アプリが 1 分あたり数十通を超えるメールを送るようになると、メールごとのポーリングでは対応できなくなります。
bounced— メールアドレスが存在しません。データベースで無効として登録し、そのアドレスへの送信を停止してください。ユーザーにはメールアドレスを更新するよう依頼します。complained— 受信者が迷惑メールとして報告しました。Portaly は、その後に送信する API メールをブロックしません。この状態を記録し、任意のメールもすべて送信しないようにしてください。suppressed— Portaly の抑制リストに登録されているため、Portaly がこのアドレスへの送信をスキップしました(以前のハードバウンス、または複数の Portaly 送信者に対する迷惑メール報告が原因です)。抑制された受信者はクォータを消費しません。soft_bounced— 再試行後も一時的なエラー(メールボックスがいっぱい、サーバーがダウンなど)が発生しています。同じアドレスで繰り返し発生しない限り、対応は不要です。deliveredは、受信側のサーバーがメールを受け付けたことを意味します。迷惑メールフォルダーに届いたかどうかは確認できません。- レスポンス内のアドレスはマスクされています(
bu***@example.com)。受信者の順序は送信時と同じなので、順番に基づいて各ステータスをレコードに対応付けてください。
バウンスと迷惑メール報告は、作成者のドメインに対してカウントされます。バウンスが続くドメインは当日中ブロックされ(403 HARD_BOUNCE_LIMIT_REACHED)、その後停止されます。bounced への対応は必須です。
6. 安全にテストする
-
コードの動作確認:サンドボックスから、アカウント所有者自身のメールアドレスに送信します。
-
バウンスと迷惑メール報告の処理:検証済みのカスタムドメインから、メールボックスシミュレーターに送信します。これらのメールは実際の受信トレイには届かず、ドメインのバウンス率や迷惑メール報告率にもカウントされません(クォータは消費します)。
アドレス 結果 [email protected]delivered[email protected]bounced[email protected]deliveredの後、complained次に
GET /api/email/emails/{id}をポーリングし、手順 5 の説明どおりに、ユーザーのコードがそのアドレスを処理することを確認します。 -
実在するプロバイダーで、でたらめなアドレスを使ってテストしないでください(
[email protected]、[email protected]など)。実際のハードバウンスはカウントされ、数件発生しただけでも作成者のドメインが停止されることがあります。
ガードレール
- この API を使ってマーケティングメールや一斉メールを送信しないでください — ニュースレター、プロモーション、コールドアウトリーチ、インポートしたリストの送信には、同意と配信停止を処理する Portaly 管理画面の一斉配信ツールを使用してください。ここから送信すると苦情につながり、クリエイターのドメインが停止され、領収書も一斉配信も送れなくなります。
- 送信対象は、メールが届くことを想定している人に限ってください — ユーザー自身の顧客に、その人が行った操作をきっかけとして送信します。
- メールを送信する公開フォームには、アドレスと IP ごとにレート制限を設けてください(パスワードリセット、サインアップ、お問い合わせ)。誰でもこれらのフォームを利用でき、短時間に大量の送信があると、クォータを消費し、苦情を招き、ドメインが停止されます。
- キーをクライアント側に絶対に公開しないでください。ログに記録せず、コミットもしないでください。
SEND_QUOTA_EXCEEDEDやその他の4xxをループ内で再試行しないでください —409は上限までにしてください(ステップ 4)。Retry-Afterを尊重し、必ず同じIdempotency-Keyで再試行してください。- テストに架空の宛先アドレスを絶対に使わないでください — サンドボックスまたはシミュレーターを使用してください。
- 開発中に実在の人へ実際のメールを送信する前に、必ず確認してください(アカウント所有者またはシミュレーター宛て以外)。送信先と内容を伝え、ユーザーの承諾を待ってください。
- フィールド、ステータス、制限を推測して追加しないでください。ここに記載されている内容と
https://portaly.ai/docsが食い違う場合は、ドキュメントを優先してください。 - キーごとの 1 分あたりのレート制限:
POST /emailsは宛先 60 件、バッチ 10 件、読み取り 120 件、キャンセルまたはドメインのバインドは 20 件です。一度に集中させず、時間を分散して一括処理してください。
成果物
- 人間向けの簡単なセットアップチェックリスト(キー、ドメイン、DNS、
.env) - 冪等性と上記のエラー処理を備えた、プロジェクトの技術スタックに対応するサーバー側の
sendEmailヘルパー bounced/complainedのアドレスを記録する配信追跡ジョブ- サンドボックスとメールボックスシミュレーターを使ったテスト手順
リソース
references/api-contract.md— すべてのエンドポイント、フィールド、ステータス、エラーコード。https://portaly.ai/docs(Email セクション)およびhttps://portaly.ai/openapi.json— 正式な仕様。../portaly-overview/SKILL.md— 同じアプリでの決済など、Portaly でできるその他のこと。