EXTENSION for treeos-connect
gateway-email
Registers the email channel type with the gateway core, enabling trees to send and receive messages over email. Output channels send notifications via SMTP using nodemailer. Any SMTP server works: Gmail, Outlook, SendGrid, Mailgun, Postmark, AWS SES, or self-hosted. SMTP credentials can be set globally via environment variables (SMTP_HOST, SMTP_USER, SMTP_PASS) or overridden per channel for multi-account setups. Input channels receive inbound email via webhook. The extension exposes a public endpoint at POST /api/v1/gateway/email/:channelId that accepts payloads from any major email service. A normalizer detects the payload format automatically: SendGrid Inbound Parse (multipart form with from/subject/text), Mailgun Routes (form with sender/body-plain), Postmark Inbound (JSON with From/Subject/TextBody), AWS SES via SNS (nested JSON with mail.source and content), or raw JSON (from/subject/text). SNS subscription confirmations are auto-confirmed. Each channel generates a unique webhook secret on creation for verifying inbound posts via query parameter or header. Input-output channels close the loop. When an inbound email is processed through the tree orchestrator and produces a reply, the extension sends that reply back to the original sender as an email with a "Re:" subject line, using the same SMTP configuration. A from-filter on the channel config optionally restricts which sender addresses or domains are accepted, preventing unwanted inbound traffic. The email subject line is prepended to the message text as context so the AI sees the topic. Sender names are extracted from the From header ("John Doe <john@example.com>" becomes "John Doe") for clean attribution in the gateway pipeline.
v1.0.1 by TreeOS Site 0 downloads 4 files 498 lines 17.5 KB published 38d ago
treeos ext install gateway-email
View changelog

Manifest

Requires

  • extensions: gateway
SHA256: 8bf34c7f7363e6cb6ccab6364316df0216ca7eca995587d32a8330cad1181844

Dependents

1 package depend on this

PackageTypeRelationship
treeos-connect v1.0.3bundleincludes

Environment Variables

KeyRequiredDescription
SMTP_HOST No SMTP server hostname (e.g., smtp.gmail.com, smtp.sendgrid.net)
SMTP_PORT No SMTP port (587 for TLS, 465 for SSL, 25 for unencrypted) (default: 587)
SMTP_USER No SMTP username or API key name
SMTP_PASS secret No SMTP password or API key
SMTP_FROM No Default from address (e.g., notifications@yourdomain.com)
EMAIL_INBOUND_SECRET secret auto No Shared secret for verifying inbound email webhooks

Source Code

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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star gateway-email

Comments

Loading comments...

Post comments from the CLI: treeos ext comment gateway-email "your comment"
Max 3 comments per extension. One star and one flag per user.