ハードバウンスとは、宛先のメールアドレスが存在しない、ドメインがないなど、恒久的な理由でメールが届かなかった状態のことです。一時的な理由で届かなかったソフトバウンスと違い、同じ宛先に送り直しても届きません。

Zoho Campaignsの画面ではキャンペーンごとにバウンスを確認できますが、その結果はZoho CRMの見込み客や連絡先には自動では残りません。営業担当は、届かないアドレスと知らずに個別メールを送り続けることになります。

この記事では、起業支援メディア運営企業の案件をもとに、ハードバウンスをCRMへ自動反映する仕組みを、Delugeコードつきで解説します。見込み客(Leads)向けに実装済みの仕組みを、連絡先(Contacts)へ広げる設計と実装を行った案件です。記事のコードは、案件のコードを1つの関数にまとめて書き直したものです。

ハードバウンスとソフトバウンスの違いは?

恒久的に届かないハードバウンスと、一時的に届かないソフトバウンスを左右に並べて比べ、CRMで不達として扱う対象を示した図。

バウンスとは、送ったメールが相手に届かずに戻ってくることです。届かない理由によって、2つに分かれます。

種類 届かない理由の例 送り直すと
ハードバウンス アドレスが存在しない、ドメインがない 届かない(恒久的)
ソフトバウンス 受信箱の容量超過など、一時的な理由 届くことがある(一時的)

CRMに「この宛先には届かない」と記録してよいのは、ハードバウンスだけです。ソフトバウンスまで同じ扱いにすると、届くはずの宛先まで配信や営業メールの対象から外してしまいます。この記事の仕組みも、ハードバウンスだけを対象にしています。

ハードバウンスをCRMへ反映するには?仕組みの全体像

処理は2つの関数と、1つのカスタムモジュールで構成します。

  1. 毎日1回、Campaignsから送信済みキャンペーンの一覧を取得し、CRMのカスタムモジュール「キャンペーン記録」に、キャンペーンキー(Campaigns側でキャンペーンを識別する値)と名前を登録する。すでに登録済みのキーは飛ばす
  2. キャンペーン記録が作られてから1週間後に、日時ベースのワークフローが不達検知の関数を呼ぶ
  3. 関数がCampaignsのAPIでハードバウンスの宛先を取得する
  4. COQL(Zoho CRMのSQLに似た検索言語)で、その宛先を持つ見込み客・連絡先を探す
  5. 見つかったレコードに、不達フラグ・最終不達日時・不達備考を書き込む

1をはさむのは、ワークフローの起点にするためです。CampaignsのキャンペーンはCRMのレコードではないので、そのままではCRMのワークフローを動かせません。CRMに1件ずつ記録を作れば、「作成日時から1週間後」という条件で関数を呼べます。

CRM側に用意する項目

見込み客と連絡先の両方に、次の3項目を用意します(項目名は一般的な名前に置き換えています)。

表示名 API名 型 用途
不達フラグ is_bounced チェックボックス 配信対象からの除外や一覧の絞り込みに使う
最終不達日時 last_bounce_date 日時 いつ不達になったかを残す
不達備考 bounce_note 複数行テキスト キャンペーン名・不達理由・検出日時を残す

キャンペーン記録のモジュールには、キャンペーンキー(Campaign_Key、一行テキスト)と名前を持たせます。

作る前に、既存の項目を確かめる

この案件では当初、連絡先に3項目を新しく作る計画でした。ところが、CRMの設定をAPIで書き出して照合すると、連絡先にも同じAPI名の3項目がすでに存在していました。以前の作業で作られていたものです。作成は不要になり、計画の手順が1つ減りました。

逆に、同じ名前で項目を作ろうとすると、API名が is_bounced1 のように別名になり、コードが参照する名前とずれます。項目を足す改修では、先に GET /settings/fields?module=Contacts などで既存の項目を一覧にし、API名を確かめてから進めてください。APIで項目を作るときの注意点は「Zoho CRMのカスタム項目をAPIで作成する」にまとめています。

不達検知の関数(Deluge)

キャンペーン記録のレコードIDを受け取り、見込み客と連絡先の両方を更新する関数です。案件ではメイン処理とモジュール別の処理を3つの関数に分けましたが、記事では読みやすさのため1つにまとめています。

void automation.markHardBouncedRecords(Int campaign_log_id)
{
	// キャンペーン記録のレコードIDを受け取り、ハードバウンスの宛先を見込み客・連絡先に反映する
	// データセンターが日本の場合は zohoapis.jp / campaigns.zoho.jp に変更
	crm_base = "https://www.zohoapis.com/crm/v8/";
	campaigns_base = "https://campaigns.zoho.com/api/v1.1/";
	target_modules = {"Leads","Contacts"};
	// 1. キャンペーン記録からキャンペーンキーと名前を取得
	campaign_log = zoho.crm.getRecordById("Campaign_Logs",campaign_log_id);
	campaign_key = ifnull(campaign_log.get("Campaign_Key"),"");
	campaign_name = ifnull(campaign_log.get("Name"),"");
	if(campaign_key == "")
	{
		info "キャンペーンキーが空のため終了: " + campaign_log_id;
		return;
	}
	// 2. ハードバウンスの宛先を取得(既定は20件なので、200件ずつページ送りする)
	page_size = 200;
	from_index = 1;
	bounces = Map();
	// メールアドレス(小文字)→ バウンス情報
	// Delugeには回数指定のループがないため、50要素のリストを作って繰り返す(最大50ページ)
	loop_str = "".leftPad(50).replaceAll(" ",",");
	page_loop = loop_str.subString(0,loop_str.length() - 1).toList();
	for each  p in page_loop
	{
		params = Map();
		params.put("resfmt","JSON");
		params.put("campaignkey",campaign_key);
		params.put("action","senthardbounce");
		params.put("fromindex",from_index);
		params.put("range",page_size);
		res = invokeurl
		[
			url :campaigns_base + "getcampaignrecipientsdata"
			type :GET
			parameters:params
			connection:"campaigns_connection"
		];
		details = res.get("list_of_details");
		if(details == null || details.size() == 0)
		{
			break;
		}
		for each  d in details
		{
			email = ifnull(d.get("contactemailaddress"),"").trim().toLowerCase();
			// COQLの文字列を壊すシングルクォート入りのアドレスは対象外にする
			if(email != "" && !email.contains("'"))
			{
				bounces.put(email,d);
			}
		}
		if(details.size() < page_size)
		{
			break;
		}
		from_index = from_index + page_size;
	}
	if(bounces.size() == 0)
	{
		info "ハードバウンスなし: " + campaign_name;
		return;
	}
	info "ハードバウンス件数: " + bounces.size();
	detected_at = zoho.currenttime.toString("yyyy年M月d日 H時m分");
	emails = bounces.keys();
	// 3. COQLの in は100値までなので、100件ずつに分けて検索・更新する
	chunk_size = 100;
	// 100件×50回=最大5,000件分。超える規模なら回数を増やす
	chunk_loop = page_loop;
	for each  module_name in target_modules
	{
		updated = 0;
		start = 0;
		for each  c in chunk_loop
		{
			if(start >= emails.size())
			{
				break;
			}
			end = start + chunk_size;
			if(end > emails.size())
			{
				end = emails.size();
			}
			chunk = emails.subList(start,end);
			start = end;
			in_values = "";
			for each  e in chunk
			{
				if(in_values != "")
				{
					in_values = in_values + ",";
				}
				in_values = in_values + "'" + e + "'";
			}
			query = "select id, Email from " + module_name + " where Email in (" + in_values + ") limit 200";
			coql = Map();
			coql.put("select_query",query);
			found = invokeurl
			[
				url :crm_base + "coql"
				type :POST
				parameters:coql.toString()
				connection:"crm_connection"
			];
			// 該当0件のときは本文のない応答が返ることがある
			if(found == null || found.toString() == "" || found.get("data") == null)
			{
				continue;
			}
			rows = List();
			for each  rec in found.get("data")
			{
				b = bounces.get(ifnull(rec.get("Email"),"").toLowerCase());
				if(b == null)
				{
					continue;
				}
				row = Map();
				row.put("id",rec.get("id"));
				row.put("is_bounced",true);
				note = "キャンペーン「" + campaign_name + "」でハードバウンスが発生しました。";
				bounced_at = ifnull(b.get("bounceddate"),"");
				if(bounced_at != "")
				{
					try 
					{
						// 当社環境では「07 Oct 2026, 10:15 AM」のような英語表記で返ってきた
						dt = bounced_at.toDateTime("dd MMM yyyy, hh:mm a","en");
						row.put("last_bounce_date",dt.toString("yyyy-MM-dd'T'HH:mm:ss+09:00"));
						note = note + "\n不達日時: " + dt.toString("yyyy年MM月dd日 HH時mm分");
					}
					catch (err)
					{
						info "日時の変換に失敗: " + bounced_at;
					}
				}
				note = note + "\n不達理由: " + ifnull(b.get("bouncereason"),"不明");
				note = note + "\n検出日時: " + detected_at;
				row.put("bounce_note",note);
				rows.add(row);
			}
			if(rows.size() > 0)
			{
				body = Map();
				body.put("data",rows);
				// この更新で他のワークフローを動かさない
				body.put("trigger",List());
				res_update = invokeurl
				[
					url :crm_base + module_name
					type :PUT
					parameters:body.toString()
					connection:"crm_connection"
				];
				updated = updated + rows.size();
			}
		}
		info module_name + " 更新件数: " + updated;
	}
}

使う前に、次の2つのコネクションを作っておきます。

  • campaigns_connection:Zoho Campaigns、スコープは ZohoCampaigns.campaign.READ
  • crm_connection:Zoho CRM、スコープは ZohoCRM.modules.ALL と ZohoCRM.coql.READ

案件では、Campaignsの認証情報を組織変数に置き、関数の中でアクセストークンを取得していました。コネクションを使えば、クライアントシークレットを組織変数やコードに置かずに済みます。

実装でつまずく点

ページ送りをしないと20件で止まる

キャンペーン受信者データAPIは、range を指定しないと1回に20件しか返しません(Zoho Campaigns API:Get Campaign Recipients Data)。なお、このAPIのメソッドは公式ドキュメントではPOSTと書かれていますが、当社の環境ではGETで動いていたため、コードはGETのままにしています。動かない場合はPOSTに変えて試してください。最初の実装はページ送りがなく、大きな配信では21件目以降を取りこぼす状態でした。fromindex(開始位置)と range(件数)を指定し、返ってきた件数が range より少なくなるまで繰り返します。

Delugeには決まった回数を回す for 文がないため、空白を並べた文字列をリストに変えて繰り返しの回数を作っています。上限(コードでは50ページ)は、配信規模に合わせて決めてください。

COQLの in は100値まで

COQLの in に書ける値は100個までです(Zoho CRM API:COQL)。バウンスが100件を超えるとクエリが通りません。コードではメールアドレスを100件ずつに分けています。テキストの値はシングルクォートで囲みます。

更新APIは1回100件まで

レコードの更新APIで一度に送れるのは100件までです(Update Records)。COQLを100件ずつに分けているので、1回の更新もおおむね100件以内に収まります。同じアドレスのレコードが複数あるときは超えることがあるため、件数が多い組織では更新の前にもう一段分けてください。

更新で他のワークフローを動かさない

更新の本文に "trigger": [](空の配列)を入れると、その更新ではワークフローなどの自動処理が動きません(Insert Records)。不達フラグの更新をきっかけに通知や別の関数が連鎖して動くのを防げます。

日時の形式とメールアドレスの大文字小文字

当社の環境では、不達日時が 07 Oct 2026, 10:15 AM のような英語表記で返ってきました。toDateTime に形式と言語("en")を渡して変換します。変換できなかった場合でも、不達フラグと備考は書き込むようにしています。

メールアドレスは、Campaigns側とCRM側で大文字小文字が違うことがあります。照合の前に小文字にそろえます。

既存の仕組みから広げるときの手順

見込み客だけに反映していた仕組みを、連絡先へ広げたときの手順です。

  1. 連絡先の既存項目をAPIで一覧にし、3項目の有無とAPI名を確かめる(この案件ではすでにあった)
  2. 無ければ3項目を作り、詳細画面のレイアウトのメールアドレス欄の近くに置く
  3. 関数を改修し、検索と更新の対象に連絡先を加える
  4. テスト用のキャンペーン記録と、バウンスする宛先を持つ連絡先を用意し、関数を手動で実行して、ログと項目の値を確かめる
  5. 複数のバウンスをまとめて更新できるか、キャンペーンキーが空のときに安全に止まるかを確かめる
  6. 本番で、見込み客用の既存ワークフローを無効にしてから、新しいワークフローを有効にする
  7. 実際のキャンペーンで動作を確かめ、見込み客側の更新が変わらず動いていることも確認する

6番は順番が大事です。見込み客用の古いワークフローを残したまま新しいワークフローを有効にすると、同じキャンペーンで処理が2回走ります。

ワークフローの設定は次のとおりです。

  • モジュール:キャンペーン記録
  • 実行の条件:日時ベース。作成日時から1週間後に1回だけ(日付・日時を基準にしたワークフローの注意点は「Zoho CRMの日付ベースのワークフローが発火しない?」で解説しています)
  • 処理:関数 markHardBouncedRecords を呼び、引数 campaign_log_id にキャンペーン記録のIDを渡す

まとめ

  • ハードバウンスは、Campaignsの受信者データAPIで action=senthardbounce を指定して取得できます
  • 送信済みキャンペーンをCRMに記録し、日時ベースのワークフローで一定期間後に関数を呼ぶと、配信のたびに自動で反映されます
  • ページ送り、COQLの in の100値、更新APIの100件という3つの上限を意識して、分割して処理します
  • 項目を足す前に、APIで既存の項目を確かめます。作らずに済むことがあります

不達を減らすには、送る側のドメイン認証も欠かせません。SPF・DKIM・DMARCの設定は「ZohoCRM / CampaignでSPF/DKIM/DMARC対応をするために」をご覧ください。

株式会社etikaのCRMサポートセンターでは、Zoho CampaignsとZoho CRMの連携や、Delugeによる自動化の設計・実装を支援しています。メール配信のデータをCRMで活かしきれていない場合は、Zoho導入支援・コンサルティングからお気軽にご相談ください。

よくある質問

ソフトバウンスも同じ方法で取れますか?

受信者データAPIの action に sentsoftbounce を指定すると取得できます。ただしソフトバウンスは一時的な不達(受信箱の容量超過など)も含むため、ハードバウンスと同じフラグを立てると、届くはずの宛先まで配信対象から外してしまいます。分けて記録するのがおすすめです。

同じメールアドレスが見込み客と連絡先の両方にあるときはどうなりますか?

両方のレコードを更新します。どちらか片方にだけフラグを立てると、もう片方を使った配信で同じ宛先に送り続けることになるためです。

不達フラグが立った連絡先は自動で配信対象から外れますか?

この記事の仕組みは、CRMにフラグを書き込むところまでです。配信リストを作るときに「不達フラグがオフ」を条件に入れる、ビューで不達の連絡先を一覧にしてメールアドレスを直す、といった運用と組み合わせて使います。

キャンペーン送信の直後に実行してはいけませんか?

送信直後はバウンスの情報が出揃っていないことがあります。当社の構成では、送信済みキャンペーンをCRMに記録してから1週間後に実行する構成にしています。期間は配信の頻度に合わせて調整してください。