branch:
index.ts
13159 bytesRaw
import { Agent, routeAgentEmail, routeAgentRequest } from "agents";
import {
createAddressBasedEmailResolver,
createSecureReplyEmailResolver,
type AgentEmail
} from "agents/email";
import PostalMime from "postal-mime";
interface EmailData {
from: string;
subject: string;
text?: string;
html?: string;
to: string;
timestamp: Date;
messageId?: string;
}
interface EmailAgentState {
emailCount: number;
lastUpdated: Date;
emails: EmailData[];
autoReplyEnabled: boolean;
}
interface Env {
EmailAgent: DurableObjectNamespace<EmailAgent>;
EMAIL_SECRET: string;
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
const DEFAULT_SECRET = "change-me-in-production";
function assertSecretConfigured(secret: string | undefined): void {
if (!secret) {
throw new Error(
"EMAIL_SECRET is not set. " +
"Please set a secret using `wrangler secret put EMAIL_SECRET` " +
"or add it to wrangler.jsonc for local development."
);
}
if (secret === DEFAULT_SECRET) {
throw new Error(
"EMAIL_SECRET has not been changed from the default value. " +
"Please set a unique secret using `wrangler secret put EMAIL_SECRET` " +
"or update the value in wrangler.jsonc for local development."
);
}
}
export class EmailAgent extends Agent<Env, EmailAgentState> {
initialState = {
autoReplyEnabled: true,
emailCount: 0,
emails: [],
lastUpdated: new Date()
};
async onEmail(email: AgentEmail) {
try {
console.log("📧 Received email from:", email.from, "to:", email.to);
const raw = await email.getRaw();
const parsed = await PostalMime.parse(raw);
console.log("📧 Parsed email:", parsed);
const emailData: EmailData = {
from: parsed.from?.address || email.from,
html: parsed.html,
messageId: parsed.messageId,
subject: parsed.subject || "No Subject",
text: parsed.text,
timestamp: new Date(),
to: email.to
};
const newState = {
autoReplyEnabled: this.state.autoReplyEnabled,
emailCount: this.state.emailCount + 1,
emails: [...this.state.emails.slice(-9), emailData],
lastUpdated: new Date()
};
this.setState(newState);
if (newState.autoReplyEnabled && !this.isAutoReply(parsed)) {
await this.replyToEmail(email, {
fromName: "My Email Agent",
body: `Thank you for your email!
I received your message with subject: "${email.headers.get("subject")}"
This is an automated response. Your email has been recorded and I will process it accordingly.
Current stats:
- Total emails processed: ${newState.emailCount}
- Last updated: ${newState.lastUpdated.toISOString()}
Best regards,
Email Agent`,
secret: this.env.EMAIL_SECRET
});
}
} catch (error) {
console.error("❌ Error processing email:", error);
throw error;
}
}
private isAutoReply(
parsed: Awaited<ReturnType<typeof PostalMime.parse>>
): boolean {
// Check headers for auto-reply indicators
// Cast header to Record to allow dynamic key access
for (const h of parsed.headers) {
const header = h as Record<string, string | undefined>;
// auto-submitted header (RFC 3834) - any value except "no" indicates auto-reply
const autoSubmitted = header["auto-submitted"];
if (autoSubmitted && autoSubmitted.toLowerCase() !== "no") {
return true;
}
// x-auto-response-suppress header (Microsoft) - presence indicates auto-reply
if (header["x-auto-response-suppress"]) {
return true;
}
// precedence header - only specific values indicate auto-reply
const precedence = header.precedence;
if (
precedence &&
["bulk", "junk", "list", "auto_reply"].includes(
precedence.toLowerCase()
)
) {
return true;
}
}
// Check subject line for common auto-reply patterns
const subject = (parsed.subject || "").toLowerCase();
return (
subject.includes("auto-reply") ||
subject.includes("out of office") ||
subject.includes("automatic reply")
);
}
}
export default {
async email(email, env: Env) {
console.log("📮 Email received via email handler");
assertSecretConfigured(env.EMAIL_SECRET);
const secureReplyResolver = createSecureReplyEmailResolver(
env.EMAIL_SECRET
);
const addressResolver = createAddressBasedEmailResolver("EmailAgent");
await routeAgentEmail(email, env, {
resolver: async (email, env) => {
// Check if this is a reply to one of our outbound emails
const replyRouting = await secureReplyResolver(email, env);
if (replyRouting) return replyRouting;
// Otherwise route based on recipient address
return addressResolver(email, env);
}
});
},
async fetch(request: Request, env: Env) {
try {
const url = new URL(request.url);
// Handle test email API endpoint
if (url.pathname === "/api/test-email" && request.method === "POST") {
assertSecretConfigured(env.EMAIL_SECRET);
const emailData = (await request.json()) as {
from?: string;
to?: string;
subject?: string;
body?: string;
};
const { from, to, subject, body } = emailData;
assert(from, "from is required");
assert(to, "to is required");
assert(subject, "subject is required");
assert(body, "body is required");
console.log("📧 Received test email data:", emailData);
// Create properly formatted RFC 5322 email message
const messageId = `<test-${crypto.randomUUID()}@test.local>`;
const rawEmail = [
`From: ${from}`,
`To: ${to}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
`Date: ${new Date().toUTCString()}`,
"Content-Type: text/plain; charset=utf-8",
"",
body
].join("\r\n");
// Create mock email from the JSON payload
const mockEmail: ForwardableEmailMessage = {
from,
to,
headers: new Headers({
subject,
"Message-ID": messageId
}),
raw: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(rawEmail));
controller.close();
}
}),
rawSize: rawEmail.length,
reply: async (message: EmailMessage) => {
console.log("📧 Reply to email:", message);
return { messageId: "mock-reply-id" };
},
forward: async (rcptTo: string, headers?: Headers) => {
console.log("📧 Forwarding email to:", rcptTo, headers);
return { messageId: "mock-forward-id" };
},
setReject: (reason: string) => {
console.log("📧 Rejecting email:", reason);
}
};
// Route the email using our email routing system
const secureReplyResolver = createSecureReplyEmailResolver(
env.EMAIL_SECRET
);
const addressResolver = createAddressBasedEmailResolver("EmailAgent");
await routeAgentEmail(mockEmail, env, {
resolver: async (email, env) => {
const replyRouting = await secureReplyResolver(email, env);
if (replyRouting) return replyRouting;
return addressResolver(email, env);
}
});
return new Response(
JSON.stringify({
success: true,
message: "Email processed successfully"
}),
{
headers: { "Content-Type": "application/json" }
}
);
}
// Security test endpoint - allows injecting custom X-Agent headers
// This is for testing the secure reply resolver's defenses
if (url.pathname === "/api/test-security" && request.method === "POST") {
assertSecretConfigured(env.EMAIL_SECRET);
const testData = (await request.json()) as {
from?: string;
to?: string;
subject?: string;
body?: string;
// Custom headers for security testing
headers?: Record<string, string>;
// If true, only use secure resolver (no fallback)
secureOnly?: boolean;
};
const {
from,
to,
subject,
body,
headers: customHeaders,
secureOnly
} = testData;
assert(from, "from is required");
assert(to, "to is required");
assert(subject, "subject is required");
assert(body, "body is required");
console.log("🔒 Security test with headers:", customHeaders);
const messageId = `<test-${crypto.randomUUID()}@test.local>`;
const emailHeaders = new Headers({
subject,
"Message-ID": messageId
});
// Add custom headers for security testing
if (customHeaders) {
for (const [key, value] of Object.entries(customHeaders)) {
emailHeaders.set(key, value);
}
}
const rawEmail = [
`From: ${from}`,
`To: ${to}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
`Date: ${new Date().toUTCString()}`,
"Content-Type: text/plain; charset=utf-8",
"",
body
].join("\r\n");
const mockEmail: ForwardableEmailMessage = {
from,
to,
headers: emailHeaders,
raw: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(rawEmail));
controller.close();
}
}),
rawSize: rawEmail.length,
reply: async (message: EmailMessage) => {
console.log("📧 Reply to email:", message);
return { messageId: "mock-reply-id" };
},
forward: async (rcptTo: string, headers?: Headers) => {
console.log("📧 Forwarding email to:", rcptTo, headers);
return { messageId: "mock-forward-id" };
},
setReject: (reason: string) => {
console.log("📧 Rejecting email:", reason);
}
};
const secureReplyResolver = createSecureReplyEmailResolver(
env.EMAIL_SECRET,
{
onInvalidSignature: (email, reason) => {
console.log(
`🔒 Signature rejected: ${reason} from ${email.from}`
);
}
}
);
const addressResolver = createAddressBasedEmailResolver("EmailAgent");
let routedVia: string | null = null;
try {
await routeAgentEmail(mockEmail, env, {
resolver: async (email, env) => {
// Try secure reply routing first
const replyRouting = await secureReplyResolver(email, env);
if (replyRouting) {
routedVia = "secure";
return replyRouting;
}
// If secureOnly, don't fall back to address resolver
if (secureOnly) {
routedVia = "rejected";
return null;
}
// Fall back to address-based routing
routedVia = "address";
return addressResolver(email, env);
}
});
return new Response(
JSON.stringify({
success: true,
routedVia,
message:
routedVia === "secure"
? "Routed via secure resolver (signature valid)"
: routedVia === "address"
? "Routed via address resolver (signature invalid/missing)"
: "Not routed (rejected)"
}),
{
headers: { "Content-Type": "application/json" }
}
);
} catch (routeError) {
return new Response(
JSON.stringify({
success: false,
routedVia,
error:
routeError instanceof Error
? routeError.message
: "Unknown error"
}),
{
headers: { "Content-Type": "application/json" },
status: 400
}
);
}
}
return (
(await routeAgentRequest(request, env)) ||
new Response("Not found", { status: 404 })
);
} catch (error) {
console.error("Fetch error in Worker:", error);
return new Response(
JSON.stringify({
error: "Internal Server Error",
message: error instanceof Error ? error.message : "Unknown error"
}),
{
headers: { "Content-Type": "application/json" },
status: 500
}
);
}
}
} satisfies ExportedHandler<Env>;