1// Email channel type handler.
2// Registered with gateway core during init.
3//
4// Output: sends via SMTP (nodemailer). Works with any SMTP server.
5// Gmail, Outlook, SendGrid, Mailgun, Postmark, AWS SES, self-hosted.
6// Config: toEmail (required). SMTP credentials from env vars or per-channel override.
7//
8// Input: receives inbound email via webhook POST from email services.
9// SendGrid Inbound Parse, Mailgun Routes, Postmark Inbound, or raw JSON.
10// The webhook URL is: POST /api/v1/gateway/email/:channelId
11// Each service sends a different format. The route normalizes all of them.
12
13import log from "../../seed/log.js";
14import crypto from "crypto";
15
16// ─────────────────────────────────────────────────────────────────────────
17// VALIDATION
18// ─────────────────────────────────────────────────────────────────────────
19
20const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
21
22function validateConfig(config, direction) {
23 const hasOutput = direction === "output" || direction === "input-output";
24 const hasInput = direction === "input" || direction === "input-output";
25
26 if (hasOutput) {
27 if (!config.toEmail || !EMAIL_RE.test(config.toEmail)) {
28 throw new Error("Email output requires a valid toEmail address");
29 }
30
31 // SMTP config: either from env or per-channel override
32 const smtpHost = config.smtpHost || process.env.SMTP_HOST;
33 if (!smtpHost) {
34 throw new Error("Email output requires SMTP_HOST in .env or smtpHost in channel config");
35 }
36 }
37
38 if (hasInput) {
39 // Input just needs a fromFilter (optional) to filter who can send
40 // The webhook URL is generated automatically
41 if (config.fromFilter && typeof config.fromFilter !== "string") {
42 throw new Error("fromFilter must be a string (email address or domain)");
43 }
44 }
45}
46
47function buildEncryptedConfig(config, direction) {
48 const hasOutput = direction === "output" || direction === "input-output";
49 const hasInput = direction === "input" || direction === "input-output";
50
51 const secrets = {};
52 const metadata = {};
53
54 if (hasOutput) {
55 // Per-channel SMTP overrides (optional, falls back to env)
56 if (config.smtpHost) secrets.smtpHost = config.smtpHost;
57 if (config.smtpPort) secrets.smtpPort = config.smtpPort;
58 if (config.smtpUser) secrets.smtpUser = config.smtpUser;
59 if (config.smtpPass) secrets.smtpPass = config.smtpPass;
60 if (config.fromEmail) secrets.fromEmail = config.fromEmail;
61
62 metadata.toEmail = config.toEmail;
63 }
64
65 if (hasInput) {
66 // Generate a webhook secret for verifying inbound posts
67 metadata.webhookSecret = crypto.randomBytes(24).toString("hex");
68 if (config.fromFilter) metadata.fromFilter = config.fromFilter;
69 }
70
71 return {
72 secrets,
73 metadata,
74 displayIdentifier: config.toEmail || config.fromFilter || "email",
75 };
76}
77
78// ─────────────────────────────────────────────────────────────────────────
79// SENDER (SMTP via nodemailer)
80// ─────────────────────────────────────────────────────────────────────────
81
82let _nodemailer = null;
83
84async function getNodemailer() {
85 if (!_nodemailer) _nodemailer = await import("nodemailer");
86 return _nodemailer;
87}
88
89async function send(secrets, metadata, notification) {
90 const nodemailer = await getNodemailer();
91
92 const host = secrets.smtpHost || process.env.SMTP_HOST;
93 const port = Number(secrets.smtpPort || process.env.SMTP_PORT || 587);
94 const user = secrets.smtpUser || process.env.SMTP_USER;
95 const pass = secrets.smtpPass || process.env.SMTP_PASS;
96 const from = secrets.fromEmail || process.env.SMTP_FROM || user;
97
98 if (!host || !user || !pass) {
99 throw new Error("SMTP not configured. Set SMTP_HOST, SMTP_USER, SMTP_PASS in .env or channel config.");
100 }
101
102 const transporter = nodemailer.createTransport({
103 host,
104 port,
105 secure: port === 465,
106 auth: { user, pass },
107 connectionTimeout: 10000,
108 greetingTimeout: 10000,
109 socketTimeout: 15000,
110 });
111
112 const subject = notification.title || "Tree notification";
113 const text = notification.content || "";
114
115 await transporter.sendMail({
116 from,
117 to: metadata.toEmail,
118 subject,
119 text,
120 });
121}
122
123// ─────────────────────────────────────────────────────────────────────────
124// INPUT LIFECYCLE
125// ─────────────────────────────────────────────────────────────────────────
126
127async function registerInput(channel, secrets) {
128 // Webhook-based input. No persistent connection needed.
129 // The webhook URL is: POST /api/v1/gateway/email/:channelId
130 // User configures their email service (SendGrid, Mailgun, etc.) to POST here.
131 const webhookSecret = channel.config?.metadata?.webhookSecret;
132 log.info("GatewayEmail",
133 `Email input registered for channel ${channel._id}. ` +
134 `Webhook: POST /api/v1/gateway/email/${channel._id}` +
135 (webhookSecret ? ` (secret: ${webhookSecret.slice(0, 8)}...)` : ""),
136 );
137}
138
139async function unregisterInput(channel, secrets) {
140 log.verbose("GatewayEmail", `Email input unregistered for channel ${channel._id}`);
141}
142
143// ─────────────────────────────────────────────────────────────────────────
144// EXPORT HANDLER
145// ─────────────────────────────────────────────────────────────────────────
146
147export default {
148 allowedDirections: ["input", "output", "input-output"],
149 validateConfig,
150 buildEncryptedConfig,
151 send,
152 registerInput,
153 unregisterInput,
154};
155
1import log from "../../seed/log.js";
2import handler from "./handler.js";
3import { getExtension } from "../loader.js";
4
5export async function init(core) {
6 const gateway = getExtension("gateway");
7 if (!gateway?.exports?.registerChannelType) {
8 throw new Error("gateway-email requires the gateway extension to be loaded first");
9 }
10
11 gateway.exports.registerChannelType("email", handler);
12 log.verbose("GatewayEmail", "Registered email channel type");
13
14 const { default: router } = await import("./routes.js");
15
16 return { router };
17}
18
1export default {
2 name: "gateway-email",
3 version: "1.0.1",
4 builtFor: "treeos-connect",
5 description:
6 "Registers the email channel type with the gateway core, enabling trees to send and " +
7 "receive messages over email. Output channels send notifications via SMTP using " +
8 "nodemailer. Any SMTP server works: Gmail, Outlook, SendGrid, Mailgun, Postmark, " +
9 "AWS SES, or self-hosted. SMTP credentials can be set globally via environment " +
10 "variables (SMTP_HOST, SMTP_USER, SMTP_PASS) or overridden per channel for " +
11 "multi-account setups." +
12 "\n\n" +
13 "Input channels receive inbound email via webhook. The extension exposes a public " +
14 "endpoint at POST /api/v1/gateway/email/:channelId that accepts payloads from any " +
15 "major email service. A normalizer detects the payload format automatically: SendGrid " +
16 "Inbound Parse (multipart form with from/subject/text), Mailgun Routes (form with " +
17 "sender/body-plain), Postmark Inbound (JSON with From/Subject/TextBody), AWS SES via " +
18 "SNS (nested JSON with mail.source and content), or raw JSON (from/subject/text). " +
19 "SNS subscription confirmations are auto-confirmed. Each channel generates a unique " +
20 "webhook secret on creation for verifying inbound posts via query parameter or header." +
21 "\n\n" +
22 "Input-output channels close the loop. When an inbound email is processed through the " +
23 "tree orchestrator and produces a reply, the extension sends that reply back to the " +
24 "original sender as an email with a \"Re:\" subject line, using the same SMTP " +
25 "configuration. A from-filter on the channel config optionally restricts which sender " +
26 "addresses or domains are accepted, preventing unwanted inbound traffic. The email " +
27 "subject line is prepended to the message text as context so the AI sees the topic. " +
28 "Sender names are extracted from the From header (\"John Doe <john@example.com>\" " +
29 "becomes \"John Doe\") for clean attribution in the gateway pipeline.",
30
31 needs: {
32 extensions: ["gateway"],
33 },
34
35 optional: {},
36
37 provides: {
38 models: {},
39 routes: false,
40 tools: false,
41 jobs: false,
42 orchestrator: false,
43 energyActions: {},
44 sessionTypes: {},
45 env: [
46 { key: "SMTP_HOST", required: false, description: "SMTP server hostname (e.g., smtp.gmail.com, smtp.sendgrid.net)" },
47 { key: "SMTP_PORT", required: false, default: "587", description: "SMTP port (587 for TLS, 465 for SSL, 25 for unencrypted)" },
48 { key: "SMTP_USER", required: false, description: "SMTP username or API key name" },
49 { key: "SMTP_PASS", required: false, secret: true, description: "SMTP password or API key" },
50 { key: "SMTP_FROM", required: false, description: "Default from address (e.g., notifications@yourdomain.com)" },
51 { key: "EMAIL_INBOUND_SECRET", required: false, secret: true, autoGenerate: true, description: "Shared secret for verifying inbound email webhooks" },
52 ],
53 cli: [],
54 },
55};
56
1// Email inbound webhook receiver.
2// No auth middleware. Email services call this directly.
3//
4// Normalizes payloads from:
5// - SendGrid Inbound Parse (multipart form: from, to, subject, text)
6// - Mailgun (form: sender, subject, body-plain)
7// - Postmark (JSON: { From, Subject, TextBody })
8// - AWS SES via SNS (JSON: { Message.mail.source, Message.content })
9// - Raw JSON (JSON: { from, subject, text })
10//
11// Webhook URL: POST /api/v1/gateway/email/:channelId
12// Optional verification: ?secret=<webhookSecret> or X-Webhook-Secret header
13
14import log from "../../seed/log.js";
15import { sendOk, sendError, ERR } from "../../seed/protocol.js";
16import express from "express";
17
18const router = express.Router();
19
20// ─────────────────────────────────────────────────────────────────────────
21// PAYLOAD NORMALIZER
22// ─────────────────────────────────────────────────────────────────────────
23
24/**
25 * Extract { from, subject, text } from any supported email service payload.
26 * Returns null if the payload is unrecognizable.
27 */
28function normalizeEmailPayload(body, contentType) {
29 if (!body) return null;
30
31 // Postmark JSON: { From, Subject, TextBody }
32 if (body.From && body.TextBody !== undefined) {
33 return {
34 from: body.From,
35 subject: body.Subject || "(no subject)",
36 text: body.TextBody || body.HtmlBody || "",
37 };
38 }
39
40 // SendGrid Inbound Parse (form data parsed by express): { from, subject, text }
41 if (body.from && body.subject !== undefined && (body.text !== undefined || body.html !== undefined)) {
42 return {
43 from: body.from,
44 subject: body.subject || "(no subject)",
45 text: body.text || body.html || "",
46 };
47 }
48
49 // Mailgun (form data): { sender, subject, body-plain }
50 if (body.sender && (body["body-plain"] !== undefined || body["body-html"] !== undefined)) {
51 return {
52 from: body.sender,
53 subject: body.subject || "(no subject)",
54 text: body["body-plain"] || body["body-html"] || "",
55 };
56 }
57
58 // AWS SES via SNS: { Type: "Notification", Message: JSON string }
59 if (body.Type === "Notification" && body.Message) {
60 try {
61 const msg = typeof body.Message === "string" ? JSON.parse(body.Message) : body.Message;
62 const mail = msg.mail || {};
63 const content = msg.content || "";
64 return {
65 from: mail.source || mail.commonHeaders?.from?.[0] || "unknown",
66 subject: mail.commonHeaders?.subject || "(no subject)",
67 text: typeof content === "string" ? content : JSON.stringify(content),
68 };
69 } catch {
70 return null;
71 }
72 }
73
74 // AWS SES SNS subscription confirmation
75 if (body.Type === "SubscriptionConfirmation" && body.SubscribeURL) {
76 // Auto-confirm SNS subscription
77 fetch(body.SubscribeURL).catch(() => {});
78 return null; // Not a message, just confirmation
79 }
80
81 // Raw JSON: { from, subject, text } or { from, body }
82 if (body.from && (body.text !== undefined || body.body !== undefined)) {
83 return {
84 from: body.from,
85 subject: body.subject || "(no subject)",
86 text: body.text || body.body || "",
87 };
88 }
89
90 return null;
91}
92
93/**
94 * Extract a clean sender name from an email From header.
95 * "John Doe <john@example.com>" -> "John Doe"
96 * "john@example.com" -> "john"
97 */
98function extractSenderName(from) {
99 if (!from) return "Unknown";
100 // "Name <email>" format
101 const match = from.match(/^([^<]+)\s*</);
102 if (match) return match[1].trim();
103 // Plain email
104 const atIdx = from.indexOf("@");
105 if (atIdx > 0) return from.slice(0, atIdx);
106 return from;
107}
108
109/**
110 * Extract email address from a From header.
111 * "John Doe <john@example.com>" -> "john@example.com"
112 */
113function extractEmail(from) {
114 if (!from) return "";
115 const match = from.match(/<([^>]+)>/);
116 if (match) return match[1].toLowerCase();
117 return from.toLowerCase().trim();
118}
119
120// ─────────────────────────────────────────────────────────────────────────
121// WEBHOOK ENDPOINT
122// ─────────────────────────────────────────────────────────────────────────
123
124// Accept both JSON and form-encoded payloads (different services use different formats)
125router.use("/gateway/email/:channelId", express.urlencoded({ extended: true, limit: "1mb" }));
126
127router.post("/gateway/email/:channelId", async (req, res) => {
128 // Respond 200 immediately (prevents retries from email services)
129 sendOk(res, { ok: true });
130
131 try {
132 const channelId = req.params.channelId;
133
134 // Load channel
135 const { getExtension } = await import("../loader.js");
136 const GatewayChannel = getExtension("gateway")?.exports?.GatewayChannel;
137 const channel = await GatewayChannel.findById(channelId).lean();
138 if (!channel || !channel.enabled) return;
139 if (channel.type !== "email") return;
140
141 const hasInput = channel.direction === "input" || channel.direction === "input-output";
142 if (!hasInput) return;
143
144 // Verify webhook secret (query param or header)
145 const expectedSecret = channel.config?.metadata?.webhookSecret;
146 if (expectedSecret) {
147 const providedSecret =
148 req.query?.secret ||
149 req.headers["x-webhook-secret"] ||
150 req.headers["x-email-webhook-secret"];
151 if (providedSecret !== expectedSecret) {
152 log.warn("GatewayEmail", `Webhook secret mismatch for channel ${channelId}`);
153 return;
154 }
155 }
156
157 // Normalize the payload
158 const normalized = normalizeEmailPayload(req.body, req.headers["content-type"]);
159 if (!normalized) {
160 log.warn("GatewayEmail", `Unrecognized email payload for channel ${channelId}`);
161 return;
162 }
163
164 // From filter: if configured, only accept emails from matching address/domain
165 const fromFilter = channel.config?.metadata?.fromFilter;
166 if (fromFilter) {
167 const senderEmail = extractEmail(normalized.from);
168 const filterLower = fromFilter.toLowerCase();
169 // Match full email or domain
170 if (senderEmail !== filterLower && !senderEmail.endsWith("@" + filterLower)) {
171 log.verbose("GatewayEmail", `Filtered out email from ${senderEmail} (filter: ${fromFilter})`);
172 return;
173 }
174 }
175
176 const senderName = extractSenderName(normalized.from);
177 const senderPlatformId = extractEmail(normalized.from);
178
179 // Build the message text. Include subject as context.
180 let messageText = normalized.text.trim();
181 if (normalized.subject && normalized.subject !== "(no subject)") {
182 messageText = `[${normalized.subject}] ${messageText}`;
183 }
184
185 if (!messageText) return;
186
187 log.verbose("GatewayEmail",
188 `Email on channel ${channelId} from ${senderName}: "${messageText.slice(0, 80)}"`,
189 );
190
191 // Process the message via gateway core
192 const gateway = getExtension("gateway");
193 if (!gateway?.exports?.processGatewayMessage) {
194 log.error("GatewayEmail", "Gateway core not loaded");
195 return;
196 }
197
198 const result = await gateway.exports.processGatewayMessage(channelId, {
199 senderName,
200 senderPlatformId,
201 messageText,
202 });
203
204 // Send reply via email if input-output and there's a reply
205 if (result.reply && channel.direction === "input-output") {
206 await sendEmailReply(channel, senderPlatformId, normalized.subject, result.reply);
207 }
208 } catch (err) {
209 log.error("GatewayEmail",
210 `Email webhook error for channel ${req.params.channelId}:`,
211 err.message,
212 );
213 }
214});
215
216// ─────────────────────────────────────────────────────────────────────────
217// REPLY SENDER
218// ─────────────────────────────────────────────────────────────────────────
219
220async function sendEmailReply(channel, toEmail, originalSubject, replyText) {
221 try {
222 const nodemailer = await import("nodemailer");
223
224 // Decrypt channel secrets for SMTP config
225 const { getExtension } = await import("../loader.js");
226 const gateway = getExtension("gateway");
227 const fullChannel = await gateway.exports.getChannelWithSecrets(channel._id);
228 const secrets = fullChannel?.config?.decryptedSecrets || {};
229
230 const host = secrets.smtpHost || process.env.SMTP_HOST;
231 const port = Number(secrets.smtpPort || process.env.SMTP_PORT || 587);
232 const user = secrets.smtpUser || process.env.SMTP_USER;
233 const pass = secrets.smtpPass || process.env.SMTP_PASS;
234 const from = secrets.fromEmail || process.env.SMTP_FROM || user;
235
236 if (!host || !user || !pass) {
237 log.warn("GatewayEmail", "Cannot send reply: SMTP not configured");
238 return;
239 }
240
241 const transporter = nodemailer.createTransport({
242 host,
243 port,
244 secure: port === 465,
245 auth: { user, pass },
246 connectionTimeout: 10000,
247 greetingTimeout: 10000,
248 socketTimeout: 15000,
249 });
250
251 const subject = originalSubject
252 ? `Re: ${originalSubject.replace(/^Re:\s*/i, "")}`
253 : "Re: your message";
254
255 await transporter.sendMail({
256 from,
257 to: toEmail,
258 subject,
259 text: replyText,
260 });
261
262 log.verbose("GatewayEmail", `Reply sent to ${toEmail}`);
263 } catch (err) {
264 log.error("GatewayEmail", `Failed to send reply to ${toEmail}: ${err.message}`);
265 }
266}
267
268export default router;
269
Loading comments...