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

View File

@ -8,7 +8,7 @@
- Email channel integration via AgentMail API
- Webhook-based inbound message handling
- 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
- Agent tool for fetching attachment download URLs on demand
- 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
- Full thread context for conversation history
- Sender allowlist/blocklist filtering
- Sender allowFrom filtering
- Attachment metadata with on-demand download URLs
- Interactive onboarding with inbox creation

View File

@ -69,8 +69,7 @@ export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
"token",
"emailAddress",
"webhookPath",
"allowlist",
"blocklist",
"allowFrom",
],
}),
isConfigured: (account) => account.configured,
@ -82,7 +81,7 @@ export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
emailAddress: account.inboxId,
}),
resolveAllowFrom: ({ cfg }) =>
((cfg as CoreConfig).channels?.agentmail?.allowlist ?? []).map((entry) =>
((cfg as CoreConfig).channels?.agentmail?.allowFrom ?? []).map((entry) =>
String(entry)
),
formatAllowFrom: ({ allowFrom }) =>
@ -93,21 +92,21 @@ export const agentmailPlugin: ChannelPlugin<ResolvedAgentMailAccount> = {
security: {
resolveDmPolicy: ({ account }) => ({
policy: "open", // AgentMail uses allowlist/blocklist instead of pairing
allowFrom: account.config.allowlist ?? [],
policyPath: "channels.agentmail.allowlist",
allowFromPath: "channels.agentmail.allowlist",
policy: "open",
allowFrom: account.config.allowFrom ?? [],
policyPath: "channels.agentmail.allowFrom",
allowFromPath: "channels.agentmail.allowFrom",
approveHint:
"Add email addresses or domains to channels.agentmail.allowlist",
"Add email addresses or domains to channels.agentmail.allowFrom",
normalizeEntry: (raw) => raw.toLowerCase().trim(),
}),
collectWarnings: ({ account }) => {
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(
"- 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(),
/** Custom webhook path for receiving emails (default: /webhooks/agentmail). */
webhookPath: z.string().optional(),
/** Allowlist of email addresses and/or domains. */
allowlist: z.array(z.string()).optional(),
/** Blocklist of email addresses and/or domains. */
blocklist: z.array(z.string()).optional(),
/** Allowed sender emails/domains. Empty = allow all. */
allowFrom: z.array(z.string()).optional(),
});

View File

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

View File

@ -1,6 +1,6 @@
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.
@ -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:
* 1. If sender is on blocklist blocked
* 2. If sender is on allowlist allowed
* 3. If allowlist is empty and not blocked allowed (open mode)
* 4. If allowlist is non-empty and sender not on it not allowed
* 1. If allowFrom is empty allowed (open mode)
* 2. If sender matches allowFrom allowed
* 3. Otherwise not allowed
*/
export function checkSenderFilter(
export function checkSenderAllowed(
senderEmail: string,
config: Pick<AgentMailConfig, "allowlist" | "blocklist">,
): FilterResult {
const { allowlist = [], blocklist = [] } = config;
config: Pick<AgentMailConfig, "allowFrom">,
): boolean {
const { allowFrom = [] } = config;
// Check blocklist first
if (matchesList(senderEmail, blocklist)) {
return { allowed: false, blocked: true, label: "blocked" };
// Open mode: all senders allowed when allowFrom is empty
if (allowFrom.length === 0) {
return true;
}
// Check allowlist
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 };
return matchesList(senderEmail, allowFrom);
}
/**
* 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,
inboxId: string,
messageId: string,
label: "allowed" | "blocked",
): Promise<void> {
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 { getAgentMailClient } from "./client.js";
import { getAgentMailRuntime } from "./runtime.js";
import { filterAndLabelMessage } from "./filtering.js";
import { checkSenderAllowed, labelMessageAllowed } from "./filtering.js";
import { extractMessageBody, fetchFormattedThread } from "./thread.js";
import { parseEmailFromAddress, parseNameFromAddress } from "./utils.js";
import type { CoreConfig, MessageReceivedEvent } from "./utils.js";
@ -111,8 +111,7 @@ export async function monitorAgentMailProvider(
}
const client = getAgentMailClient(apiKey);
const allowlist = agentmailConfig?.allowlist ?? [];
const blocklist = agentmailConfig?.blocklist ?? [];
const allowFrom = agentmailConfig?.allowFrom ?? [];
recordState(accountId, {
running: true,
@ -152,21 +151,16 @@ export async function monitorAgentMailProvider(
logVerbose(`agentmail: received message from ${senderEmail}`);
// Apply sender filtering
const filterResult = await filterAndLabelMessage(
client,
inboxId,
message.messageId,
senderEmail,
{ allowlist, blocklist }
);
const allowed = checkSenderAllowed(senderEmail, { allowFrom });
if (!filterResult.allowed) {
logVerbose(
`agentmail: sender ${senderEmail} not allowed (blocked=${filterResult.blocked})`
);
if (!allowed) {
logVerbose(`agentmail: sender ${senderEmail} not in allowFrom`);
return sendJson(res, 200, { ok: true, filtered: true });
}
// Label message as allowed
await labelMessageAllowed(client, inboxId, message.messageId);
recordState(accountId, { lastInboundAt: Date.now() });
// 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);
});
it("adds allowlist", () => {
it("adds allowFrom", () => {
const cfg = {} as never;
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",
"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", () => {
const cfg = {} as never;
const result: AnyConfig = updateAgentMailConfig(cfg, {

View File

@ -263,13 +263,13 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
// Save webhook config
next = updateAgentMailConfig(next, { webhookUrl, webhookPath });
// Ask about allowlist
const addAllowlist = await prompter.confirm({
message: "Add senders to allowlist? (Empty = allow all non-blocked)",
// Ask about allowFrom
const addAllowFrom = await prompter.confirm({
message: "Add senders to allowFrom? (Empty = allow all)",
initialValue: false,
});
if (addAllowlist) {
if (addAllowFrom) {
const entry = String(
await prompter.text({
message:
@ -279,28 +279,8 @@ export const agentmailOnboardingAdapter: ChannelOnboardingAdapter = {
if (entry) {
const existing =
(next as CoreConfig).channels?.agentmail?.allowlist ?? [];
next = updateAgentMailConfig(next, { allowlist: [...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] });
(next as CoreConfig).channels?.agentmail?.allowFrom ?? [];
next = updateAgentMailConfig(next, { allowFrom: [...existing, entry] });
}
}

View File

@ -34,13 +34,6 @@ export type Message = AgentMail.messages.Message;
/** Re-export SDK webhook event type. */
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. */
export function formatUtcDate(date: Date | string | number): string {
return new Date(date).toUTCString().replace("GMT", "UTC");