allowFrom

This commit is contained in:
Haakam Aujla 2026-01-27 13:06:24 -08:00
parent 22eb3d52a1
commit f399bdfab1
11 changed files with 77 additions and 171 deletions

View File

@ -83,20 +83,18 @@ Minimal config:
| `emailAddress` | string | AgentMail inbox email address to monitor (required) | | `emailAddress` | string | AgentMail inbox email address to monitor (required) |
| `webhookUrl` | string | Gateway public base URL (e.g., `https://gw.ngrok.io`) | | `webhookUrl` | string | Gateway public base URL (e.g., `https://gw.ngrok.io`) |
| `webhookPath` | string | Custom webhook path (default: `/webhooks/agentmail`) | | `webhookPath` | string | Custom webhook path (default: `/webhooks/agentmail`) |
| `allowlist` | string[] | Allowed sender emails/domains | | `allowFrom` | string[] | Allowed sender emails/domains (empty = allow all) |
| `blocklist` | string[] | Blocked sender emails/domains |
## Sender Filtering ## Sender Filtering
AgentMail supports allowlist and blocklist filtering for incoming emails. Both lists accept AgentMail uses `allowFrom` to filter incoming emails. The list accepts email addresses and domains.
email addresses and domains.
### Allowlist/Blocklist Logic ### Filtering Logic
1. If sender is on the blocklist, the message is labeled `blocked` and ignored 1. If `allowFrom` is empty, all senders are allowed (open mode)
2. If sender is on the allowlist, the message is labeled `allowed` and triggers Clawdbot 2. If `allowFrom` is non-empty, only matching senders trigger Clawdbot
3. If allowlist is empty and sender is not blocked, the message is `allowed` (open mode) 3. Allowed messages are labeled `allowed` in AgentMail
4. If allowlist is non-empty and sender is not on it, the message is ignored 4. Non-matching senders are silently ignored
### Example Configuration ### Example Configuration
@ -108,9 +106,7 @@ email addresses and domains.
token: "am_***", token: "am_***",
emailAddress: "clawd@agentmail.to", emailAddress: "clawd@agentmail.to",
// Allow specific emails and domains // Allow specific emails and domains
allowlist: ["alice@example.com", "trusted-domain.org"], allowFrom: ["alice@example.com", "trusted-domain.org"],
// Block specific emails and domains
blocklist: ["spam@bad.com", "bad-domain.net"],
}, },
}, },
} }
@ -120,8 +116,7 @@ email addresses and domains.
Domain entries match any email from that domain: Domain entries match any email from that domain:
- `example.org` in allowlist allows `alice@example.org`, `bob@example.org`, etc. - `example.org` in allowFrom allows `alice@example.org`, `bob@example.org`, etc.
- `bad.com` in blocklist blocks `spam@bad.com`, `anything@bad.com`, etc.
## Thread Context ## Thread Context
@ -187,6 +182,6 @@ Then register the ngrok URL as your webhook endpoint in the AgentMail dashboard.
### Sender not allowed ### Sender not allowed
1. Check the `allowlist` and `blocklist` configuration 1. Check the `allowFrom` configuration
2. Verify the sender email matches the expected format 2. Verify the sender email matches an entry in allowFrom
3. Check if the sender's domain is blocked 3. Remember: empty allowFrom means all senders are allowed

View File

@ -8,7 +8,7 @@
- Email channel integration via AgentMail API - Email channel integration via AgentMail API
- Webhook-based inbound message handling - Webhook-based inbound message handling
- Full thread context fetching for conversation history - Full thread context fetching for conversation history
- Sender allowlist and blocklist filtering with automatic labeling - Sender allowFrom filtering (consistent with other channels)
- Attachment metadata in thread context - Attachment metadata in thread context
- Agent tool for fetching attachment download URLs on demand - Agent tool for fetching attachment download URLs on demand
- Interactive onboarding flow with inbox creation support - Interactive onboarding flow with inbox creation support

View File

@ -50,7 +50,7 @@ Register a webhook in the AgentMail dashboard:
- Webhook-based inbound email handling - Webhook-based inbound email handling
- Full thread context for conversation history - Full thread context for conversation history
- Sender allowlist/blocklist filtering - Sender allowFrom filtering
- Attachment metadata with on-demand download URLs - Attachment metadata with on-demand download URLs
- Interactive onboarding with inbox creation - Interactive onboarding with inbox creation

View File

@ -69,8 +69,7 @@ export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
"token", "token",
"emailAddress", "emailAddress",
"webhookPath", "webhookPath",
"allowlist", "allowFrom",
"blocklist",
], ],
}), }),
isConfigured: (account) => account.configured, isConfigured: (account) => account.configured,
@ -82,7 +81,7 @@ export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
emailAddress: account.inboxId, emailAddress: account.inboxId,
}), }),
resolveAllowFrom: ({ cfg }) => resolveAllowFrom: ({ cfg }) =>
((cfg as CoreConfig).channels?.agentmail?.allowlist ?? []).map((entry) => ((cfg as CoreConfig).channels?.agentmail?.allowFrom ?? []).map((entry) =>
String(entry) String(entry)
), ),
formatAllowFrom: ({ allowFrom }) => formatAllowFrom: ({ allowFrom }) =>
@ -93,21 +92,21 @@ export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
security: { security: {
resolveDmPolicy: ({ account }) => ({ resolveDmPolicy: ({ account }) => ({
policy: "open", // AgentMail uses allowlist/blocklist instead of pairing policy: "open",
allowFrom: account.config.allowlist ?? [], allowFrom: account.config.allowFrom ?? [],
policyPath: "channels.agentmail.allowlist", policyPath: "channels.agentmail.allowFrom",
allowFromPath: "channels.agentmail.allowlist", allowFromPath: "channels.agentmail.allowFrom",
approveHint: approveHint:
"Add email addresses or domains to channels.agentmail.allowlist", "Add email addresses or domains to channels.agentmail.allowFrom",
normalizeEntry: (raw) => raw.toLowerCase().trim(), normalizeEntry: (raw) => raw.toLowerCase().trim(),
}), }),
collectWarnings: ({ account }) => { collectWarnings: ({ account }) => {
const warnings: string[] = []; const warnings: string[] = [];
const { allowlist = [], blocklist = [] } = account.config; const { allowFrom = [] } = account.config;
if (allowlist.length === 0 && blocklist.length === 0) { if (allowFrom.length === 0) {
warnings.push( warnings.push(
"- AgentMail: No allowlist or blocklist configured. All senders will be allowed." "- AgentMail: No allowFrom configured. All senders will be allowed."
); );
} }

View File

@ -17,8 +17,6 @@ export const AgentMailConfigSchema = z.object({
webhookUrl: z.string().optional(), webhookUrl: z.string().optional(),
/** Custom webhook path for receiving emails (default: /webhooks/agentmail). */ /** Custom webhook path for receiving emails (default: /webhooks/agentmail). */
webhookPath: z.string().optional(), webhookPath: z.string().optional(),
/** Allowlist of email addresses and/or domains. */ /** Allowed sender emails/domains. Empty = allow all. */
allowlist: z.array(z.string()).optional(), allowFrom: z.array(z.string()).optional(),
/** Blocklist of email addresses and/or domains. */
blocklist: z.array(z.string()).optional(),
}); });

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { checkSenderFilter, matchesList } from "./filtering.js"; import { checkSenderAllowed, matchesList } from "./filtering.js";
describe("matchesList", () => { describe("matchesList", () => {
it("returns false for empty list", () => { it("returns false for empty list", () => {
@ -32,44 +32,32 @@ describe("matchesList", () => {
}); });
}); });
describe("checkSenderFilter", () => { describe("checkSenderAllowed", () => {
it("blocks sender on blocklist", () => { it("allows sender on allowFrom", () => {
const result = checkSenderFilter("spam@bad.com", { const result = checkSenderAllowed("friend@good.com", {
blocklist: ["bad.com"], allowFrom: ["good.com"],
allowlist: [],
}); });
expect(result).toEqual({ allowed: false, blocked: true, label: "blocked" }); expect(result).toBe(true);
}); });
it("allows sender on allowlist", () => { it("allows all in open mode (empty allowFrom)", () => {
const result = checkSenderFilter("friend@good.com", { const result = checkSenderAllowed("anyone@anywhere.com", {
blocklist: [], allowFrom: [],
allowlist: ["good.com"],
}); });
expect(result).toEqual({ allowed: true, blocked: false, label: "allowed" }); expect(result).toBe(true);
}); });
it("allows all non-blocked in open mode (empty allowlist)", () => { it("rejects sender not on non-empty allowFrom", () => {
const result = checkSenderFilter("anyone@anywhere.com", { const result = checkSenderAllowed("stranger@unknown.com", {
blocklist: [], allowFrom: ["trusted.com"],
allowlist: [],
}); });
expect(result).toEqual({ allowed: true, blocked: false, label: "allowed" }); expect(result).toBe(false);
}); });
it("rejects sender not on non-empty allowlist", () => { it("matches exact email in allowFrom", () => {
const result = checkSenderFilter("stranger@unknown.com", { const result = checkSenderAllowed("user@example.com", {
blocklist: [], allowFrom: ["user@example.com"],
allowlist: ["trusted.com"],
}); });
expect(result).toEqual({ allowed: false, blocked: false, label: null }); expect(result).toBe(true);
});
it("blocklist takes precedence over allowlist", () => {
const result = checkSenderFilter("user@both.com", {
blocklist: ["both.com"],
allowlist: ["both.com"],
});
expect(result).toEqual({ allowed: false, blocked: true, label: "blocked" });
}); });
}); });

View File

@ -1,6 +1,6 @@
import type { AgentMailClient } from "agentmail"; import type { AgentMailClient } from "agentmail";
import type { AgentMailConfig, FilterResult } from "./utils.js"; import type { AgentMailConfig } from "./utils.js";
/** /**
* Checks if a sender email matches any entry in a list. * Checks if a sender email matches any entry in a list.
@ -23,69 +23,36 @@ export function matchesList(senderEmail: string, list: string[]): boolean {
} }
/** /**
* Determines the filter result for a sender based on allowlist/blocklist. * Determines if a sender is allowed based on allowFrom config.
* *
* Logic: * Logic:
* 1. If sender is on blocklist blocked * 1. If allowFrom is empty allowed (open mode)
* 2. If sender is on allowlist allowed * 2. If sender matches allowFrom allowed
* 3. If allowlist is empty and not blocked allowed (open mode) * 3. Otherwise not allowed
* 4. If allowlist is non-empty and sender not on it not allowed
*/ */
export function checkSenderFilter( export function checkSenderAllowed(
senderEmail: string, senderEmail: string,
config: Pick<AgentMailConfig, "allowlist" | "blocklist">, config: Pick<AgentMailConfig, "allowFrom">,
): FilterResult { ): boolean {
const { allowlist = [], blocklist = [] } = config; const { allowFrom = [] } = config;
// Check blocklist first // Open mode: all senders allowed when allowFrom is empty
if (matchesList(senderEmail, blocklist)) { if (allowFrom.length === 0) {
return { allowed: false, blocked: true, label: "blocked" }; return true;
} }
// Check allowlist return matchesList(senderEmail, allowFrom);
if (allowlist.length === 0) {
// Open mode: all non-blocked senders are allowed
return { allowed: true, blocked: false, label: "allowed" };
}
if (matchesList(senderEmail, allowlist)) {
return { allowed: true, blocked: false, label: "allowed" };
}
// Not on allowlist and allowlist is non-empty
return { allowed: false, blocked: false, label: null };
} }
/** /**
* Labels a message via the AgentMail API. * Labels a message as "allowed" via the AgentMail API.
*/ */
export async function labelMessage( export async function labelMessageAllowed(
client: AgentMailClient, client: AgentMailClient,
inboxId: string, inboxId: string,
messageId: string, messageId: string,
label: "allowed" | "blocked",
): Promise<void> { ): Promise<void> {
await client.inboxes.messages.update(inboxId, messageId, { await client.inboxes.messages.update(inboxId, messageId, {
addLabels: [label], addLabels: ["allowed"],
}); });
} }
/**
* Processes an incoming message and applies filtering.
* Returns the filter result and labels the message if needed.
*/
export async function filterAndLabelMessage(
client: AgentMailClient,
inboxId: string,
messageId: string,
senderEmail: string,
config: Pick<AgentMailConfig, "allowlist" | "blocklist">,
): Promise<FilterResult> {
const result = checkSenderFilter(senderEmail, config);
if (result.label) {
await labelMessage(client, inboxId, messageId, result.label);
}
return result;
}

View File

@ -5,7 +5,7 @@ import { registerPluginHttpRoute } from "clawdbot/plugin-sdk";
import { resolveAgentMailAccount, resolveCredentials } from "./accounts.js"; import { resolveAgentMailAccount, resolveCredentials } from "./accounts.js";
import { getAgentMailClient } from "./client.js"; import { getAgentMailClient } from "./client.js";
import { getAgentMailRuntime } from "./runtime.js"; import { getAgentMailRuntime } from "./runtime.js";
import { filterAndLabelMessage } from "./filtering.js"; import { checkSenderAllowed, labelMessageAllowed } from "./filtering.js";
import { extractMessageBody, fetchFormattedThread } from "./thread.js"; import { extractMessageBody, fetchFormattedThread } from "./thread.js";
import { parseEmailFromAddress, parseNameFromAddress } from "./utils.js"; import { parseEmailFromAddress, parseNameFromAddress } from "./utils.js";
import type { CoreConfig, MessageReceivedEvent } from "./utils.js"; import type { CoreConfig, MessageReceivedEvent } from "./utils.js";
@ -111,8 +111,7 @@ export async function monitorAgentMailProvider(
} }
const client = getAgentMailClient(apiKey); const client = getAgentMailClient(apiKey);
const allowlist = agentmailConfig?.allowlist ?? []; const allowFrom = agentmailConfig?.allowFrom ?? [];
const blocklist = agentmailConfig?.blocklist ?? [];
recordState(accountId, { recordState(accountId, {
running: true, running: true,
@ -152,21 +151,16 @@ export async function monitorAgentMailProvider(
logVerbose(`agentmail: received message from ${senderEmail}`); logVerbose(`agentmail: received message from ${senderEmail}`);
// Apply sender filtering // Apply sender filtering
const filterResult = await filterAndLabelMessage( const allowed = checkSenderAllowed(senderEmail, { allowFrom });
client,
inboxId,
message.messageId,
senderEmail,
{ allowlist, blocklist }
);
if (!filterResult.allowed) { if (!allowed) {
logVerbose( logVerbose(`agentmail: sender ${senderEmail} not in allowFrom`);
`agentmail: sender ${senderEmail} not allowed (blocked=${filterResult.blocked})`
);
return sendJson(res, 200, { ok: true, filtered: true }); return sendJson(res, 200, { ok: true, filtered: true });
} }
// Label message as allowed
await labelMessageAllowed(client, inboxId, message.messageId);
recordState(accountId, { lastInboundAt: Date.now() }); recordState(accountId, { lastInboundAt: Date.now() });
// Fetch the full thread from API (handles webhook size limits for large messages) // Fetch the full thread from API (handles webhook size limits for large messages)

View File

@ -77,25 +77,17 @@ describe("updateAgentMailConfig", () => {
expect(result.channels?.agentmail?.enabled).toBe(true); expect(result.channels?.agentmail?.enabled).toBe(true);
}); });
it("adds allowlist", () => { it("adds allowFrom", () => {
const cfg = {} as never; const cfg = {} as never;
const result: AnyConfig = updateAgentMailConfig(cfg, { const result: AnyConfig = updateAgentMailConfig(cfg, {
allowlist: ["user@example.com", "example.org"], allowFrom: ["user@example.com", "example.org"],
}); });
expect(result.channels?.agentmail?.allowlist).toEqual([ expect(result.channels?.agentmail?.allowFrom).toEqual([
"user@example.com", "user@example.com",
"example.org", "example.org",
]); ]);
}); });
it("adds blocklist", () => {
const cfg = {} as never;
const result: AnyConfig = updateAgentMailConfig(cfg, {
blocklist: ["spam@bad.com"],
});
expect(result.channels?.agentmail?.blocklist).toEqual(["spam@bad.com"]);
});
it("sets webhookPath", () => { it("sets webhookPath", () => {
const cfg = {} as never; const cfg = {} as never;
const result: AnyConfig = updateAgentMailConfig(cfg, { const result: AnyConfig = updateAgentMailConfig(cfg, {

View File

@ -263,13 +263,13 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
// Save webhook config // Save webhook config
next = updateAgentMailConfig(next, { webhookUrl, webhookPath }); next = updateAgentMailConfig(next, { webhookUrl, webhookPath });
// Ask about allowlist // Ask about allowFrom
const addAllowlist = await prompter.confirm({ const addAllowFrom = await prompter.confirm({
message: "Add senders to allowlist? (Empty = allow all non-blocked)", message: "Add senders to allowFrom? (Empty = allow all)",
initialValue: false, initialValue: false,
}); });
if (addAllowlist) { if (addAllowFrom) {
const entry = String( const entry = String(
await prompter.text({ await prompter.text({
message: message:
@ -279,28 +279,8 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
if (entry) { if (entry) {
const existing = const existing =
(next as CoreConfig).channels?.agentmail?.allowlist ?? []; (next as CoreConfig).channels?.agentmail?.allowFrom ?? [];
next = updateAgentMailConfig(next, { allowlist: [...existing, entry] }); next = updateAgentMailConfig(next, { allowFrom: [...existing, entry] });
}
}
// Ask about blocklist
const addBlocklist = await prompter.confirm({
message: "Add senders to blocklist? (Block spam/unwanted emails)",
initialValue: false,
});
if (addBlocklist) {
const entry = String(
await prompter.text({
message: "Email or domain to block (e.g., spam@bad.com or bad.com)",
})
).trim();
if (entry) {
const existing =
(next as CoreConfig).channels?.agentmail?.blocklist ?? [];
next = updateAgentMailConfig(next, { blocklist: [...existing, entry] });
} }
} }

View File

@ -34,13 +34,6 @@ export type Message = AgentMail.messages.Message;
/** Re-export SDK webhook event type. */ /** Re-export SDK webhook event type. */
export type MessageReceivedEvent = AgentMail.events.MessageReceivedEvent; export type MessageReceivedEvent = AgentMail.events.MessageReceivedEvent;
/** Result of sender filtering check. */
export type FilterResult = {
allowed: boolean;
blocked: boolean;
label: "allowed" | "blocked" | null;
};
/** Formats a date as a UTC string. */ /** Formats a date as a UTC string. */
export function formatUtcDate(date: Date | string | number): string { export function formatUtcDate(date: Date | string | number): string {
return new Date(date).toUTCString().replace("GMT", "UTC"); return new Date(date).toUTCString().replace("GMT", "UTC");