Bladeren bron

feat: add SMTP, Telegram and Nextcloud Talk nodes, fix loop branching and node config

Three new builtin nodes, plus the fixes their first real use turned up.

Nodes:
- smtp-send: sends mail through libcurl, with attachments taken inline from a
  previous node or read from the file store. New JS API smartbotic.smtp.send.
- telegram-send: messages, photos and documents, by URL or by uploading a
  stored file as multipart. Bot token comes from an api_key or bearer
  credential rather than sitting in the node config.
- nextcloud-talk: posts to and reads a Talk conversation over the OCS API.

Credentials:
- New smtp credential type carrying host, port, security, and the sender.
  An imap credential is also accepted where a node sends from a mailbox it
  already reads, so one account is not stored twice.
- The node credential picker takes a list of types, not only one.

Workflow engine:
- A loop body node whose upstream was skipped no longer falls back to the raw
  loop item. It was running the whole tail of an untaken branch on every item,
  which is how a notify node fired once per item instead of not at all.
- Testing a single node inside a loop runs the body once, up to that node,
  using the outputs pinned in the editor for everything before it.
- The editor now sends pinned data as cached output, and config hashes are
  compared as parsed JSON so JSON.stringify order does not defeat the cache.

Error reporting:
- JSON.parse is wrapped once per script context: a non-JSON body now names
  itself as HTML, XML or empty and shows what arrived, instead of throwing
  "unexpected token: '<'" with no context.
- Stack traces survive both the thrown and the rejected-promise paths, so an
  error names the line it came from.

Node definitions:
- The schema parser now reads JavaScript string literals properly. Braces in a
  default value and an escaped apostrophe in a description each cost a node its
  entire configuration form - the code node could not be edited at all.
- Migration refreshes a stored definition when a fresh parse disagrees with it,
  so a fixed parser reaches nodes imported by the broken one.

WebUI:
- Config fields are auto-growing textareas that report what they hold: JSON
  (with a format action), JSON that does not parse, or markdown. Code fields
  get a monospace editor with room to work.
fszontagh 1 maand geleden
bovenliggende
commit
7cc00da427

+ 3 - 0
.gitignore

@@ -59,3 +59,6 @@ coverage/
 
 # Packaging output
 dist/
+
+# Playwright MCP session output
+.playwright-mcp/

+ 1 - 0
CMakeLists.txt

@@ -207,6 +207,7 @@ set(RUNNER_SOURCES
     src/runner/collection_permissions.cpp
     src/runner/engine/script_engine.cpp
     src/runner/imap/imap_client.cpp
+    src/runner/smtp/smtp_client.cpp
 )
 
 # Add MySQL client if library is found

+ 28 - 0
docs/nodes.md

@@ -227,6 +227,34 @@ if (auth.success) {
 }
 ```
 
+### Sending Email
+
+```javascript
+const result = smartbotic.smtp.send({
+    credentialId: config.credentialId,  // an smtp credential, or the imap one for the same mailbox
+    to: ['someone@example.com'],        // a single address or an array
+    cc: [],
+    bcc: [],
+    subject: 'Subject line',
+    body: 'Message text',
+    html: false,                        // true sends the body as text/html
+    replyTo: '',
+    from: '',                           // overrides the sender on the credential
+    fromName: '',
+    attachments: [
+        { filename: 'note.txt', mimeType: 'text/plain', contentBase64: '...' }
+    ]
+});
+// result.messageId - the Message-ID the mail went out with
+// result.accepted  - how many addresses it was submitted for
+```
+
+An SMTP credential holds `host`, `port`, `username`, `password`, `security`
+(`starttls`, `ssl` or `none`), and optionally `from_address` and `from_name`.
+An IMAP credential is accepted in its place: the same account is reached on the
+submission port with STARTTLS, so a mailbox stored for reading mail does not
+have to be entered again to send it.
+
 ### Utilities
 
 ```javascript

+ 36 - 0
lib/credentials/credential_client.cpp

@@ -81,6 +81,42 @@ Result<ImapData> CredentialClient::getImapCredentials(const std::string& credent
     return data;
 }
 
+Result<SmtpData> CredentialClient::getSmtpCredentials(const std::string& credential_id,
+                                                       const std::string& workflow_id) {
+    proto::GetSmtpCredentialsRequest request;
+    request.set_credential_id(credential_id);
+    request.set_workflow_id(workflow_id);
+
+    proto::GetSmtpCredentialsResponse response;
+    grpc::ClientContext context;
+
+    auto deadline = std::chrono::system_clock::now() +
+                   std::chrono::milliseconds(config_.timeout_ms);
+    context.set_deadline(deadline);
+
+    grpc::Status status = stub_->GetSmtpCredentials(&context, request, &response);
+
+    if (!status.ok()) {
+        return Error(ErrorCode::Unavailable,
+                    "Credential service error: " + status.error_message());
+    }
+
+    if (!response.success()) {
+        return Error(ErrorCode::NotFound, response.error());
+    }
+
+    SmtpData data;
+    data.host = response.host();
+    data.port = response.port();
+    data.username = response.username();
+    data.password = response.password();
+    data.security = response.security();
+    data.from_address = response.from_address();
+    data.from_name = response.from_name();
+
+    return data;
+}
+
 Result<MysqlData> CredentialClient::getMysqlCredentials(const std::string& credential_id,
                                                          const std::string& workflow_id) {
     proto::GetMysqlCredentialsRequest request;

+ 4 - 0
lib/credentials/credential_client.hpp

@@ -30,6 +30,10 @@ public:
     common::Result<ImapData> getImapCredentials(const std::string& credential_id,
                                                  const std::string& workflow_id = "");
 
+    // Also satisfied by an imap credential for the same account.
+    common::Result<SmtpData> getSmtpCredentials(const std::string& credential_id,
+                                                const std::string& workflow_id = "");
+
     // Get MySQL credentials
     common::Result<MysqlData> getMysqlCredentials(const std::string& credential_id,
                                                    const std::string& workflow_id = "");

+ 73 - 0
lib/credentials/credential_store.cpp

@@ -138,6 +138,22 @@ Result<CredentialInfo> CredentialStore::create(const CreateCredentialRequest& re
             };
             break;
         }
+        case CredentialType::Smtp: {
+            auto data = SmtpData::fromJson(request.data);
+            if (data.host.empty() || data.username.empty() || data.password.empty()) {
+                return Error(ErrorCode::InvalidArgument, "SMTP requires host, username, and password");
+            }
+            data_to_encrypt = data.toJson();
+            public_data = {
+                {"host", data.host},
+                {"port", data.port},
+                {"username", data.username},
+                {"security", data.security},
+                {"from_address", data.from_address},
+                {"from_name", data.from_name}
+            };
+            break;
+        }
         case CredentialType::Mysql: {
             auto data = MysqlData::fromJson(request.data);
             if (data.host.empty() || data.username.empty() || data.password.empty()) {
@@ -274,6 +290,19 @@ Result<void> CredentialStore::update(const std::string& id, const UpdateCredenti
                 };
                 break;
             }
+            case CredentialType::Smtp: {
+                auto data = SmtpData::fromJson(request.data);
+                data_to_encrypt = data.toJson();
+                public_data = {
+                    {"host", data.host},
+                    {"port", data.port},
+                    {"username", data.username},
+                    {"security", data.security},
+                    {"from_address", data.from_address},
+                    {"from_name", data.from_name}
+                };
+                break;
+            }
             case CredentialType::Mysql: {
                 auto data = MysqlData::fromJson(request.data);
                 data_to_encrypt = data.toJson();
@@ -415,6 +444,8 @@ Result<HttpAuth> CredentialStore::getHttpAuth(const std::string& id, const std::
         }
         case CredentialType::Imap:
             return Error(ErrorCode::InvalidArgument, "IMAP credentials cannot be used for HTTP authentication");
+        case CredentialType::Smtp:
+            return Error(ErrorCode::InvalidArgument, "SMTP credentials cannot be used for HTTP authentication");
         case CredentialType::Mysql:
             return Error(ErrorCode::InvalidArgument, "MySQL credentials cannot be used for HTTP authentication");
         case CredentialType::Postgresql:
@@ -453,6 +484,48 @@ Result<ImapData> CredentialStore::getImapCredentials(const std::string& id, cons
     return ImapData::fromJson(decrypt_result.value());
 }
 
+Result<SmtpData> CredentialStore::getSmtpCredentials(const std::string& id, const std::string& workflow_id) {
+    auto get_result = storage_.get(COLLECTION, id);
+    if (get_result.failed()) {
+        return Error(ErrorCode::NotFound, "Credential not found: " + id);
+    }
+
+    auto doc = CredentialDocument::fromJson(get_result.value());
+
+    const bool is_smtp = doc.metadata.type == CredentialType::Smtp;
+    const bool is_imap = doc.metadata.type == CredentialType::Imap;
+    if (!is_smtp && !is_imap) {
+        return Error(ErrorCode::InvalidArgument,
+            "Credential is neither an SMTP nor an IMAP credential");
+    }
+
+    if (!hasWorkflowAccess(doc.metadata, workflow_id)) {
+        return Error(ErrorCode::PermissionDenied,
+            "Workflow " + workflow_id + " does not have access to credential " + id);
+    }
+
+    auto decrypt_result = decryptData(doc.encrypted_data);
+    if (decrypt_result.failed()) {
+        return decrypt_result.error();
+    }
+
+    if (is_smtp) {
+        return SmtpData::fromJson(decrypt_result.value());
+    }
+
+    // An IMAP credential names a mailbox, not a way to send from it, so the
+    // sending details are the conventional ones for that account.
+    auto imap = ImapData::fromJson(decrypt_result.value());
+    SmtpData data;
+    data.host = imap.host;
+    data.port = 587;
+    data.username = imap.username;
+    data.password = imap.password;
+    data.security = "starttls";
+    data.from_address = imap.username;
+    return data;
+}
+
 Result<MysqlData> CredentialStore::getMysqlCredentials(const std::string& id, const std::string& workflow_id) {
     // Get document
     auto get_result = storage_.get(COLLECTION, id);

+ 5 - 0
lib/credentials/credential_store.hpp

@@ -37,6 +37,11 @@ public:
     // Get IMAP credentials (checks workflow access)
     common::Result<ImapData> getImapCredentials(const std::string& id, const std::string& workflow_id = "");
 
+    // Accepts an smtp credential, or an imap one, so the same mailbox does not
+    // have to be stored twice to both read and send mail. An imap credential is
+    // adapted using the submission port and STARTTLS.
+    common::Result<SmtpData> getSmtpCredentials(const std::string& id, const std::string& workflow_id = "");
+
     // Get MySQL credentials (checks workflow access)
     common::Result<MysqlData> getMysqlCredentials(const std::string& id, const std::string& workflow_id = "");
 

+ 26 - 0
lib/credentials/credential_types.cpp

@@ -14,6 +14,7 @@ std::string credentialTypeToString(CredentialType type) {
         case CredentialType::ApiKey: return "api_key";
         case CredentialType::OAuth2: return "oauth2";
         case CredentialType::Imap: return "imap";
+        case CredentialType::Smtp: return "smtp";
         case CredentialType::Mysql: return "mysql";
         case CredentialType::Postgresql: return "postgresql";
         default: return "unknown";
@@ -26,6 +27,7 @@ CredentialType credentialTypeFromString(const std::string& str) {
     if (str == "api_key") return CredentialType::ApiKey;
     if (str == "oauth2") return CredentialType::OAuth2;
     if (str == "imap") return CredentialType::Imap;
+    if (str == "smtp") return CredentialType::Smtp;
     if (str == "mysql") return CredentialType::Mysql;
     if (str == "postgresql") return CredentialType::Postgresql;
     throw std::invalid_argument("Unknown credential type: " + str);
@@ -167,6 +169,30 @@ HttpAuth OAuth2Data::toHttpAuth() const {
 }
 
 // ImapData
+nlohmann::json SmtpData::toJson() const {
+    return {
+        {"host", host},
+        {"port", port},
+        {"username", username},
+        {"password", password},
+        {"security", security},
+        {"fromAddress", from_address},
+        {"fromName", from_name}
+    };
+}
+
+SmtpData SmtpData::fromJson(const nlohmann::json& j) {
+    SmtpData data;
+    data.host = j.value("host", "");
+    data.port = j.value("port", 587);
+    data.username = j.value("username", "");
+    data.password = j.value("password", "");
+    data.security = j.value("security", std::string("starttls"));
+    data.from_address = j.value("fromAddress", j.value("from_address", ""));
+    data.from_name = j.value("fromName", j.value("from_name", ""));
+    return data;
+}
+
 nlohmann::json ImapData::toJson() const {
     return {
         {"host", host},

+ 17 - 0
lib/credentials/credential_types.hpp

@@ -14,6 +14,7 @@ enum class CredentialType {
     ApiKey,     // API Key: header name + key value
     OAuth2,     // OAuth2: client credentials with token refresh
     Imap,       // IMAP: host + port + username + password + SSL
+    Smtp,       // SMTP: host + port + username + password + security + sender
     Mysql,      // MySQL: host + port + username + password + database
     Postgresql  // PostgreSQL: host + port + username + password + database
 };
@@ -93,6 +94,22 @@ struct ImapData {
     static ImapData fromJson(const nlohmann::json& j);
 };
 
+// SMTP data (stored encrypted). An smtp-send node also accepts an Imap
+// credential, so an account already stored for reading mail need not be entered
+// a second time to send it.
+struct SmtpData {
+    std::string host;
+    int port = 587;               // Default submission port
+    std::string username;
+    std::string password;
+    std::string security = "starttls";  // "starttls", "ssl" or "none"
+    std::string from_address;     // Envelope sender; defaults to username when empty
+    std::string from_name;
+
+    nlohmann::json toJson() const;
+    static SmtpData fromJson(const nlohmann::json& j);
+};
+
 // MySQL data (stored encrypted)
 struct MysqlData {
     std::string host;

+ 273 - 0
nodes/integration/nextcloud-talk.js

@@ -0,0 +1,273 @@
+/**
+ * @node nextcloud-talk
+ * @name Nextcloud Talk
+ * @category integration
+ * @version 1.0.0
+ * @description Post a message to a Nextcloud Talk conversation, or read the recent messages in one
+ * @icon message-square
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        baseUrl: {
+            type: 'string',
+            title: 'Nextcloud URL',
+            description: 'Address of the Nextcloud instance, e.g. https://cloud.example.com'
+        },
+        credentialId: {
+            type: 'string',
+            title: 'Credential',
+            description: 'A basic credential holding the username and an app password. Not needed for a bot webhook.',
+            dynamicOptions: {
+                source: 'credentials',
+                filter: { type: 'basic' }
+            }
+        },
+        action: {
+            type: 'string',
+            title: 'Action',
+            enum: ['send', 'read'],
+            default: 'send',
+            description: 'Post a message, or read the recent ones'
+        },
+        token: {
+            type: 'string',
+            title: 'Conversation Token',
+            description: 'The token in the conversation URL, e.g. the a1b2c3d4 in /call/a1b2c3d4'
+        },
+        message: {
+            type: 'string',
+            title: 'Message',
+            description: 'What to post. Supports {{variable}} interpolation.'
+        },
+        replyTo: {
+            type: 'string',
+            title: 'Reply To',
+            description: 'Post as a reply to this message id'
+        },
+        silent: {
+            type: 'boolean',
+            title: 'Send Silently',
+            description: 'Post without notifying participants',
+            default: false
+        },
+        limit: {
+            type: 'integer',
+            title: 'Limit',
+            description: 'How many messages to read',
+            default: 20,
+            minimum: 1,
+            maximum: 200
+        },
+        timeoutMs: {
+            type: 'number',
+            title: 'Timeout (ms)',
+            default: 30000
+        }
+    },
+    required: ['baseUrl', 'credentialId', 'token']
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        token: { type: 'string', description: 'Overrides the configured conversation' },
+        message: { type: 'string', description: 'Overrides the configured message' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        success: { type: 'boolean' },
+        messageId: { type: 'integer', description: 'The id Talk gave the posted message' },
+        token: { type: 'string', description: 'The conversation it was posted to' },
+        messages: {
+            type: 'array',
+            description: 'Recent messages, when reading',
+            items: {
+                type: 'object',
+                properties: {
+                    id: { type: 'integer' },
+                    actorId: { type: 'string' },
+                    actorDisplayName: { type: 'string' },
+                    message: { type: 'string' },
+                    timestamp: { type: 'integer' }
+                }
+            }
+        },
+        count: { type: 'integer', description: 'How many messages were read' }
+    }
+};
+
+const outputs = [
+    { name: 'main', displayName: 'Result', type: 'object', color: '#3b82f6' }
+];
+
+// A connection hands the previous node's whole output over as input.data, and
+// some nodes wrap their own return in a result object on top of that. Rather
+// than guess which shape arrived, the layers are flattened with the innermost
+// winning, so a field set anywhere in the chain is found.
+function incoming(input) {
+    const isPlain = (v) => v && typeof v === 'object' && !Array.isArray(v);
+    const merged = {};
+
+    for (const layer of [input, input && input.data, input && input.data && input.data.result]) {
+        if (!isPlain(layer)) continue;
+        for (const key of Object.keys(layer)) {
+            merged[key] = layer[key];
+        }
+    }
+
+    return merged;
+}
+
+// Talk is reached through the OCS API, which refuses any request that does not
+// declare itself as an API call with this header, and returns XML unless asked
+// for JSON.
+function ocsHeaders(credential) {
+    const headers = {
+        'OCS-APIRequest': 'true',
+        'Accept': 'application/json',
+        'Content-Type': 'application/json'
+    };
+    if (credential && credential.headerName) {
+        headers[credential.headerName] = credential.headerValue;
+    }
+    return headers;
+}
+
+function readCredential(credentialId) {
+    const credential = smartbotic.credentials.get(credentialId);
+    if (!credential || !credential.success) {
+        throw new Error('Could not read the Nextcloud credential: ' +
+            ((credential && credential.error) || 'unknown error'));
+    }
+    return credential;
+}
+
+// A Nextcloud that is unhappy answers with XML, or with the HTML of a login
+// page, and parsing that as JSON throws a bare syntax error that says nothing
+// about which request failed. The body is reported instead.
+function parseJsonResponse(response, what) {
+    if (typeof response.data !== 'string') {
+        return response.data;
+    }
+
+    try {
+        return JSON.parse(response.data);
+    } catch (e) {
+        const snippet = response.data.substring(0, 200).replace(/\s+/g, ' ');
+        throw new Error('Nextcloud Talk ' + what + ' failed: HTTP ' + response.status +
+            ' returned ' + (response.headers && response.headers['content-type'] || 'an unreadable body') +
+            ' instead of JSON: ' + snippet);
+    }
+}
+
+// OCS wraps everything in ocs.meta plus ocs.data, and reports failure in the
+// meta rather than always in the status code.
+function unwrapOcs(response, what) {
+    const body = parseJsonResponse(response, what);
+    const ocs = body && body.ocs;
+
+    if (!ocs) {
+        throw new Error('Nextcloud Talk ' + what + ' failed: HTTP ' + response.status +
+            ' returned no OCS envelope: ' + JSON.stringify(body).substring(0, 200));
+    }
+
+    const status = ocs.meta && ocs.meta.statuscode;
+    if (status && (status < 200 || status >= 300)) {
+        throw new Error('Nextcloud Talk ' + what + ' failed: ' +
+            ((ocs.meta && ocs.meta.message) || ('OCS ' + status)));
+    }
+
+    return ocs.data;
+}
+
+module.exports = {
+    configSchema,
+    inputSchema,
+    outputSchema,
+    outputs,
+
+    async execute(config, input, context) {
+        const from = incoming(input);
+
+        const base = String(config.baseUrl || '').replace(/\/+$/, '');
+        if (!base) {
+            throw new Error('The Nextcloud URL is required');
+        }
+
+        const token = String(from.token || config.token || '').trim();
+        if (!token) {
+            throw new Error('A conversation token is required');
+        }
+
+        const credential = readCredential(config.credentialId);
+        const headers = ocsHeaders(credential);
+        const timeout = Number(config.timeoutMs) || 30000;
+        const chatUrl = base + '/ocs/v2.php/apps/spreed/api/v1/chat/' + token;
+
+        if ((config.action || 'send') === 'read') {
+            const limit = Number(config.limit) || 20;
+
+            // lookIntoFuture=0 asks for the messages already there rather than
+            // holding the connection open waiting for the next one.
+            const response = smartbotic.http.request({
+                method: 'GET',
+                url: chatUrl + '?format=json&lookIntoFuture=0&limit=' + limit,
+                headers: headers,
+                timeout: timeout
+            });
+
+            const data = unwrapOcs(response, 'read') || [];
+            const messages = data.map(function (entry) {
+                return {
+                    id: entry.id,
+                    actorId: entry.actorId,
+                    actorDisplayName: entry.actorDisplayName,
+                    message: entry.message,
+                    timestamp: entry.timestamp
+                };
+            });
+
+            return {
+                success: true,
+                token: token,
+                messages: messages,
+                count: messages.length
+            };
+        }
+
+        const message = from.message || config.message || '';
+        if (!message) {
+            throw new Error('A message is required');
+        }
+
+        const payload = { message: message };
+        if (config.replyTo) {
+            payload.replyTo = Number(config.replyTo);
+        }
+        if (config.silent === true) {
+            payload.silent = true;
+        }
+
+        const response = smartbotic.http.request({
+            method: 'POST',
+            url: chatUrl + '?format=json',
+            headers: headers,
+            body: JSON.stringify(payload),
+            timeout: timeout
+        });
+
+        const data = unwrapOcs(response, 'send') || {};
+        smartbotic.log.info('Nextcloud Talk: posted to conversation ' + token);
+
+        return {
+            success: true,
+            messageId: data.id,
+            token: token
+        };
+    }
+};

+ 281 - 0
nodes/integration/telegram-send.js

@@ -0,0 +1,281 @@
+/**
+ * @node telegram-send
+ * @name Telegram
+ * @category integration
+ * @version 1.0.0
+ * @description Send a message, photo or document to a Telegram chat through a bot
+ * @icon send
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        credentialId: {
+            type: 'string',
+            title: 'Bot Token Credential',
+            description: 'An API key or bearer credential holding the bot token from BotFather',
+            dynamicOptions: {
+                source: 'credentials',
+                filter: { type: ['api_key', 'bearer'] }
+            }
+        },
+        chatId: {
+            type: 'string',
+            title: 'Chat ID',
+            description: 'Numeric chat id, or @channelusername for a public channel'
+        },
+        action: {
+            type: 'string',
+            title: 'Action',
+            enum: ['sendMessage', 'sendPhoto', 'sendDocument'],
+            default: 'sendMessage',
+            description: 'What to send'
+        },
+        text: {
+            type: 'string',
+            title: 'Text',
+            description: 'Message text, or the caption for a photo or document. Supports {{variable}} interpolation.'
+        },
+        parseMode: {
+            type: 'string',
+            title: 'Formatting',
+            enum: ['none', 'Markdown', 'MarkdownV2', 'HTML'],
+            default: 'none',
+            description: 'How Telegram should interpret markup in the text'
+        },
+        fileUrl: {
+            type: 'string',
+            title: 'File URL',
+            description: 'Address of the photo or document to send. Telegram fetches it itself.'
+        },
+        fileId: {
+            type: 'string',
+            title: 'Stored File ID',
+            description: 'File store id to upload instead of giving Telegram an address'
+        },
+        disableNotification: {
+            type: 'boolean',
+            title: 'Send Silently',
+            description: 'Deliver without a notification sound',
+            default: false
+        },
+        replyToMessageId: {
+            type: 'string',
+            title: 'Reply To Message ID',
+            description: 'Send as a reply to this message'
+        },
+        apiBaseUrl: {
+            type: 'string',
+            title: 'API Base URL',
+            description: 'Telegram Bot API address, for a self-hosted API server',
+            default: 'https://api.telegram.org'
+        },
+        timeoutMs: {
+            type: 'number',
+            title: 'Timeout (ms)',
+            default: 30000
+        }
+    },
+    required: ['credentialId', 'chatId']
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        chatId: { type: 'string', description: 'Overrides the configured chat' },
+        text: { type: 'string', description: 'Overrides the configured text' },
+        fileUrl: { type: 'string', description: 'Overrides the configured file address' },
+        fileId: { type: 'string', description: 'Overrides the configured stored file' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        success: { type: 'boolean' },
+        messageId: { type: 'integer', description: 'The id Telegram gave the sent message' },
+        chatId: { type: 'string' },
+        date: { type: 'integer', description: 'When Telegram accepted it, in seconds' }
+    }
+};
+
+const outputs = [
+    { name: 'main', displayName: 'Sent', type: 'object', color: '#3b82f6' }
+];
+
+// A connection hands the previous node's whole output over as input.data, and
+// some nodes wrap their own return in a result object on top of that. Rather
+// than guess which shape arrived, the layers are flattened with the innermost
+// winning, so a field set anywhere in the chain is found.
+function incoming(input) {
+    const isPlain = (v) => v && typeof v === 'object' && !Array.isArray(v);
+    const merged = {};
+
+    for (const layer of [input, input && input.data, input && input.data && input.data.result]) {
+        if (!isPlain(layer)) continue;
+        for (const key of Object.keys(layer)) {
+            merged[key] = layer[key];
+        }
+    }
+
+    return merged;
+}
+
+// The bot token belongs in the URL rather than a header, so the stored header
+// value is unwrapped: a bearer credential arrives prefixed, an api key does not.
+function readBotToken(credentialId) {
+    const credential = smartbotic.credentials.get(credentialId);
+    if (!credential || !credential.success) {
+        throw new Error('Could not read the bot token credential: ' +
+            ((credential && credential.error) || 'unknown error'));
+    }
+
+    const value = String(credential.headerValue || '').trim();
+    const token = value.indexOf('Bearer ') === 0 ? value.substring(7).trim() : value;
+    if (!token) {
+        throw new Error('The credential holds no bot token');
+    }
+    return token;
+}
+
+// Telegram accepts a file either as an address it fetches or as an upload. A
+// stored file has no address Telegram's servers can reach, so its bytes are
+// posted as multipart form data instead.
+function uploadStoredFile(url, field, fileId, fields, timeout) {
+    const info = smartbotic.storage.getFileInfo(fileId);
+    const file = smartbotic.storage.downloadFile(fileId);
+    if (!file || !file.success || !file.data) {
+        throw new Error('Stored file ' + fileId + ' could not be read');
+    }
+
+    // Every value in a form part is sent as text, so the flags Telegram expects
+    // as booleans or numbers are written out as strings here.
+    const formData = {};
+    for (const key of Object.keys(fields)) {
+        formData[key] = String(fields[key]);
+    }
+
+    return smartbotic.http.request({
+        method: 'POST',
+        url: url,
+        formData: formData,
+        files: [{
+            name: field,
+            filename: (info && info.name) || fileId,
+            mimeType: (info && info.mimeType) || 'application/octet-stream',
+            data: file.data
+        }],
+        timeout: timeout
+    });
+}
+
+module.exports = {
+    configSchema,
+    inputSchema,
+    outputSchema,
+    outputs,
+
+    async execute(config, input, context) {
+        const from = incoming(input);
+
+        if (!config.credentialId) {
+            throw new Error('A bot token credential is required');
+        }
+
+        const chatId = String(from.chatId || config.chatId || '').trim();
+        if (!chatId) {
+            throw new Error('A chat id is required');
+        }
+
+        const action = config.action || 'sendMessage';
+        const text = from.text || config.text || '';
+        const token = readBotToken(config.credentialId);
+        const base = String(config.apiBaseUrl || 'https://api.telegram.org').replace(/\/+$/, '');
+
+        const payload = { chat_id: chatId };
+
+        if (config.parseMode && config.parseMode !== 'none') {
+            payload.parse_mode = config.parseMode;
+        }
+        if (config.disableNotification === true) {
+            payload.disable_notification = true;
+        }
+        if (config.replyToMessageId) {
+            payload.reply_to_message_id = Number(config.replyToMessageId);
+        }
+
+        const url = base + '/bot' + token + '/' + action;
+        const timeout = Number(config.timeoutMs) || 30000;
+        const fileUrl = from.fileUrl || config.fileUrl;
+        const fileId = from.fileId || config.fileId;
+
+        let response;
+
+        if (action === 'sendMessage') {
+            if (!text) {
+                throw new Error('Message text is required');
+            }
+            payload.text = text;
+
+            response = smartbotic.http.request({
+                method: 'POST',
+                url: url,
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify(payload),
+                timeout: timeout
+            });
+        } else {
+            const field = action === 'sendPhoto' ? 'photo' : 'document';
+            if (text) payload.caption = text;
+
+            if (fileId) {
+                response = uploadStoredFile(url, field, fileId, payload, timeout);
+            } else if (fileUrl) {
+                // Telegram fetches the file itself from the address it is given,
+                // which avoids moving the bytes through this node. The address
+                // has to be one Telegram's servers can reach.
+                payload[field] = fileUrl;
+
+                response = smartbotic.http.request({
+                    method: 'POST',
+                    url: url,
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify(payload),
+                    timeout: timeout
+                });
+            } else {
+                throw new Error(action + ' requires a file URL or a stored file id');
+            }
+        }
+
+        let body = response.data;
+        if (typeof body === 'string') {
+            try {
+                body = JSON.parse(body);
+            } catch (e) {
+                // A proxy or a wrong API base answers with HTML, and parsing it
+                // as JSON throws a syntax error that names neither the request
+                // nor the response.
+                throw new Error('Telegram ' + action + ' failed: HTTP ' + response.status +
+                    ' returned a non-JSON body: ' + body.substring(0, 200).replace(/\s+/g, ' '));
+            }
+        }
+
+        // Telegram reports its own failures in the body with a 4xx status and a
+        // description that says far more than the status code does.
+        if (!body || body.ok !== true) {
+            const description = (body && body.description) || ('HTTP ' + response.status);
+            throw new Error('Telegram ' + action + ' failed: ' + description);
+        }
+
+        const message = body.result || {};
+        smartbotic.log.info('Telegram: ' + action + ' to chat ' + chatId);
+
+        return {
+            success: true,
+            messageId: message.message_id,
+            chatId: chatId,
+            date: message.date
+        };
+    }
+};

+ 220 - 0
nodes/smtp/smtp-send.js

@@ -0,0 +1,220 @@
+/**
+ * @node smtp-send
+ * @name Send Email
+ * @category email
+ * @version 1.0.0
+ * @description Send an email through an SMTP server, with optional attachments taken from the file store
+ * @icon send
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        credentialId: {
+            type: 'string',
+            title: 'Mail Credential',
+            description: 'An SMTP credential, or the IMAP credential for the same mailbox',
+            dynamicOptions: {
+                source: 'credentials',
+                filter: { type: ['smtp', 'imap'] }
+            }
+        },
+        to: {
+            type: 'string',
+            title: 'To',
+            description: 'Recipient address, or several separated by commas'
+        },
+        cc: {
+            type: 'string',
+            title: 'Cc',
+            description: 'Copied recipients, separated by commas'
+        },
+        bcc: {
+            type: 'string',
+            title: 'Bcc',
+            description: 'Blind copied recipients, separated by commas'
+        },
+        subject: {
+            type: 'string',
+            title: 'Subject'
+        },
+        body: {
+            type: 'string',
+            title: 'Body',
+            description: 'Message text. Supports {{variable}} interpolation.'
+        },
+        html: {
+            type: 'boolean',
+            title: 'Send as HTML',
+            description: 'Send the body as HTML rather than plain text',
+            default: false
+        },
+        replyTo: {
+            type: 'string',
+            title: 'Reply-To',
+            description: 'Address replies should go to, when it differs from the sender'
+        },
+        fromAddress: {
+            type: 'string',
+            title: 'From',
+            description: 'Sender address. Defaults to the one on the credential.'
+        },
+        fromName: {
+            type: 'string',
+            title: 'From Name',
+            description: 'Name shown to recipients alongside the address'
+        },
+        attachmentFileIds: {
+            type: 'string',
+            title: 'Attachment File IDs',
+            description: 'File store ids to attach, separated by commas'
+        }
+    },
+    required: ['credentialId', 'to']
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        to: { type: 'string', description: 'Overrides the configured recipients' },
+        subject: { type: 'string', description: 'Overrides the configured subject' },
+        body: { type: 'string', description: 'Overrides the configured body' },
+        attachments: {
+            type: 'array',
+            description: 'Attachments supplied by an earlier node',
+            items: {
+                type: 'object',
+                properties: {
+                    filename: { type: 'string' },
+                    mimeType: { type: 'string' },
+                    contentBase64: { type: 'string', description: 'Attachment content, base64 encoded' },
+                    fileId: { type: 'string', description: 'File store id to read the content from instead' }
+                }
+            }
+        }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        success: { type: 'boolean' },
+        messageId: { type: 'string', description: 'The Message-ID the mail was sent with' },
+        accepted: { type: 'integer', description: 'How many addresses the message was submitted for' },
+        to: { type: 'array', items: { type: 'string' } },
+        subject: { type: 'string' }
+    }
+};
+
+const outputs = [
+    { name: 'main', displayName: 'Sent', type: 'object', color: '#3b82f6' }
+];
+
+// A connection hands the previous node's whole output over as input.data, and
+// some nodes wrap their own return in a result object on top of that. Rather
+// than guess which shape arrived, the layers are flattened with the innermost
+// winning, so a field set anywhere in the chain is found.
+function incoming(input) {
+    const isPlain = (v) => v && typeof v === 'object' && !Array.isArray(v);
+    const merged = {};
+
+    for (const layer of [input, input && input.data, input && input.data && input.data.result]) {
+        if (!isPlain(layer)) continue;
+        for (const key of Object.keys(layer)) {
+            merged[key] = layer[key];
+        }
+    }
+
+    return merged;
+}
+
+// Addresses are written the way people write them - one line, commas between -
+// so the node splits rather than asking for an array in the form.
+function splitAddresses(value) {
+    if (!value) return [];
+    if (Array.isArray(value)) return value.map(String).map(a => a.trim()).filter(Boolean);
+    return String(value).split(',').map(a => a.trim()).filter(Boolean);
+}
+
+// An attachment is either carried inline by the previous node or left in the
+// file store; a file id means the bytes have to be fetched before sending.
+function resolveAttachment(entry) {
+    if (entry.contentBase64) {
+        return {
+            filename: entry.filename || 'attachment',
+            mimeType: entry.mimeType || '',
+            contentBase64: entry.contentBase64
+        };
+    }
+
+    if (!entry.fileId) return null;
+
+    const info = smartbotic.storage.getFileInfo(entry.fileId);
+    const file = smartbotic.storage.downloadFile(entry.fileId);
+    if (!file || !file.success || !file.data) return null;
+
+    return {
+        filename: entry.filename || (info && info.name) || entry.fileId,
+        mimeType: entry.mimeType || (info && info.mimeType) || '',
+        contentBase64: file.data
+    };
+}
+
+module.exports = {
+    configSchema,
+    inputSchema,
+    outputSchema,
+    outputs,
+
+    async execute(config, input, context) {
+        const from = incoming(input);
+
+        if (!config.credentialId) {
+            throw new Error('A mail credential is required');
+        }
+
+        const to = splitAddresses(from.to || config.to);
+        if (to.length === 0) {
+            throw new Error('At least one recipient is required');
+        }
+
+        const subject = from.subject || config.subject || '';
+        const body = from.body || config.body || '';
+
+        const attachments = [];
+
+        for (const entry of (from.attachments || [])) {
+            const resolved = resolveAttachment(entry);
+            if (resolved) attachments.push(resolved);
+        }
+
+        for (const fileId of splitAddresses(config.attachmentFileIds)) {
+            const resolved = resolveAttachment({ fileId });
+            if (resolved) attachments.push(resolved);
+        }
+
+        const result = smartbotic.smtp.send({
+            credentialId: config.credentialId,
+            to,
+            cc: splitAddresses(config.cc),
+            bcc: splitAddresses(config.bcc),
+            subject,
+            body,
+            html: config.html === true,
+            replyTo: config.replyTo || '',
+            from: config.fromAddress || '',
+            fromName: config.fromName || '',
+            attachments
+        });
+
+        smartbotic.log.info('Sent "' + subject + '" to ' + to.join(', '));
+
+        return {
+            success: true,
+            messageId: result.messageId,
+            accepted: result.accepted,
+            to,
+            subject
+        };
+    }
+};

+ 24 - 1
proto/credentials.proto

@@ -12,6 +12,9 @@ service CredentialService {
     // Get IMAP credentials for a credential
     rpc GetImapCredentials(GetImapCredentialsRequest) returns (GetImapCredentialsResponse);
 
+    // Get SMTP credentials for a credential
+    rpc GetSmtpCredentials(GetSmtpCredentialsRequest) returns (GetSmtpCredentialsResponse);
+
     // Get MySQL credentials for a credential
     rpc GetMysqlCredentials(GetMysqlCredentialsRequest) returns (GetMysqlCredentialsResponse);
 
@@ -40,7 +43,7 @@ message GetCredentialAuthResponse {
 message CredentialInfo {
     string id = 1;
     string name = 2;
-    string type = 3;  // "basic", "bearer", "api_key", "oauth2", "imap", "mysql", "postgresql"
+    string type = 3;  // "basic", "bearer", "api_key", "oauth2", "imap", "smtp", "mysql", "postgresql"
     string description = 4;
 }
 
@@ -61,6 +64,26 @@ message GetImapCredentialsResponse {
     string error = 7;  // Error message if success is false
 }
 
+// Request to get SMTP credentials
+message GetSmtpCredentialsRequest {
+    string credential_id = 1;
+    string workflow_id = 2;  // For access control verification
+}
+
+// Response with SMTP credentials. An imap credential answers here too, so an
+// account stored for reading mail can also be used to send it.
+message GetSmtpCredentialsResponse {
+    bool success = 1;
+    string host = 2;
+    int32 port = 3;
+    string username = 4;
+    string password = 5;
+    string security = 6;      // "starttls", "ssl" or "none"
+    string from_address = 7;
+    string from_name = 8;
+    string error = 9;
+}
+
 // Request to get MySQL credentials
 message GetMysqlCredentialsRequest {
     string credential_id = 1;

+ 223 - 1
src/runner/engine/script_engine.cpp

@@ -2,6 +2,7 @@
 #include "common/uuid.hpp"
 #include "logging/logger.hpp"
 #include "runner/imap/imap_client.hpp"
+#include "runner/smtp/smtp_client.hpp"
 #ifdef MYSQL_SUPPORT
 #include "runner/mysql/mysql_client.hpp"
 #endif
@@ -3707,6 +3708,183 @@ static void setupImapAPI(JSContext* ctx, JSValue smartbotic) {
     JS_SetPropertyStr(ctx, smartbotic, "imap", imap);
 }
 
+// Reads a string property, leaving the value untouched when it is absent.
+static std::string smtpStringProp(JSContext* ctx, JSValue obj, const char* name) {
+    std::string out;
+    JSValue val = JS_GetPropertyStr(ctx, obj, name);
+    if (JS_IsString(val)) {
+        const char* c = JS_ToCString(ctx, val);
+        if (c) { out = c; JS_FreeCString(ctx, c); }
+    }
+    JS_FreeValue(ctx, val);
+    return out;
+}
+
+// A recipient list is written either as one address or as an array of them,
+// since sending to a single person is the common case and should not need [].
+static std::vector<std::string> smtpAddressList(JSContext* ctx, JSValue obj, const char* name) {
+    std::vector<std::string> out;
+    JSValue val = JS_GetPropertyStr(ctx, obj, name);
+
+    if (JS_IsString(val)) {
+        const char* c = JS_ToCString(ctx, val);
+        if (c) {
+            if (*c) out.emplace_back(c);
+            JS_FreeCString(ctx, c);
+        }
+    } else if (JS_IsArray(ctx, val)) {
+        JSValue len_val = JS_GetPropertyStr(ctx, val, "length");
+        int32_t len = 0;
+        JS_ToInt32(ctx, &len, len_val);
+        JS_FreeValue(ctx, len_val);
+
+        for (int32_t i = 0; i < len; i++) {
+            JSValue item = JS_GetPropertyUint32(ctx, val, i);
+            const char* c = JS_ToCString(ctx, item);
+            if (c) {
+                if (*c) out.emplace_back(c);
+                JS_FreeCString(ctx, c);
+            }
+            JS_FreeValue(ctx, item);
+        }
+    }
+
+    JS_FreeValue(ctx, val);
+    return out;
+}
+
+// smtp.send(options) - Send an email
+static JSValue js_smtp_send(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) {
+    const ScriptContext* script_ctx = getScriptContext(ctx);
+    if (!script_ctx || !script_ctx->credentials_get_smtp) {
+        return JS_ThrowInternalError(ctx, "SMTP API not available");
+    }
+
+    if (argc < 1 || !JS_IsObject(argv[0])) {
+        return JS_ThrowTypeError(ctx, "smtp.send requires an options object");
+    }
+
+    JSValue options = argv[0];
+
+    const std::string credential_id = smtpStringProp(ctx, options, "credentialId");
+    if (credential_id.empty()) {
+        return JS_ThrowTypeError(ctx, "smtp.send requires credentialId");
+    }
+
+    smtp::Message message;
+    message.to = smtpAddressList(ctx, options, "to");
+    message.cc = smtpAddressList(ctx, options, "cc");
+    message.bcc = smtpAddressList(ctx, options, "bcc");
+    message.subject = smtpStringProp(ctx, options, "subject");
+    message.body = smtpStringProp(ctx, options, "body");
+    message.reply_to = smtpStringProp(ctx, options, "replyTo");
+
+    JSValue html_val = JS_GetPropertyStr(ctx, options, "html");
+    message.html = JS_ToBool(ctx, html_val) == 1;
+    JS_FreeValue(ctx, html_val);
+
+    if (message.to.empty()) {
+        return JS_ThrowTypeError(ctx, "smtp.send requires at least one recipient in 'to'");
+    }
+
+    JSValue attachments_val = JS_GetPropertyStr(ctx, options, "attachments");
+    if (JS_IsArray(ctx, attachments_val)) {
+        JSValue len_val = JS_GetPropertyStr(ctx, attachments_val, "length");
+        int32_t len = 0;
+        JS_ToInt32(ctx, &len, len_val);
+        JS_FreeValue(ctx, len_val);
+
+        for (int32_t i = 0; i < len; i++) {
+            JSValue item = JS_GetPropertyUint32(ctx, attachments_val, i);
+            if (JS_IsObject(item)) {
+                smtp::Attachment attachment;
+                attachment.filename = smtpStringProp(ctx, item, "filename");
+                attachment.mime_type = smtpStringProp(ctx, item, "mimeType");
+                attachment.content_base64 = smtpStringProp(ctx, item, "contentBase64");
+                if (!attachment.filename.empty() && !attachment.content_base64.empty()) {
+                    message.attachments.push_back(std::move(attachment));
+                }
+            }
+            JS_FreeValue(ctx, item);
+        }
+    }
+    JS_FreeValue(ctx, attachments_val);
+
+    auto cred_result = script_ctx->credentials_get_smtp(credential_id);
+    if (cred_result.failed()) {
+        return JS_ThrowInternalError(ctx, "Failed to get SMTP credentials: %s",
+                                     cred_result.error().message().c_str());
+    }
+
+    const auto& cred = cred_result.value();
+    smtp::SmtpCredentials smtp_creds{cred.host, cred.port, cred.username, cred.password,
+                                     cred.security, cred.from_address, cred.from_name};
+
+    // The credential names the account, but a workflow may legitimately send as
+    // a different address on it, so an explicit sender overrides the stored one.
+    const std::string from_override = smtpStringProp(ctx, options, "from");
+    if (!from_override.empty()) {
+        smtp_creds.from_address = from_override;
+    }
+    const std::string from_name_override = smtpStringProp(ctx, options, "fromName");
+    if (!from_name_override.empty()) {
+        smtp_creds.from_name = from_name_override;
+    }
+
+    smtp::SmtpClient client(smtp_creds);
+    auto send_result = client.send(message);
+    if (send_result.failed()) {
+        return JS_ThrowInternalError(ctx, "%s", send_result.error().message().c_str());
+    }
+
+    JSValue result = JS_NewObject(ctx);
+    JS_SetPropertyStr(ctx, result, "success", JS_TRUE);
+    JS_SetPropertyStr(ctx, result, "messageId",
+                      JS_NewString(ctx, send_result.value().message_id.c_str()));
+    JS_SetPropertyStr(ctx, result, "accepted",
+                      JS_NewInt32(ctx, static_cast<int32_t>(
+                          message.to.size() + message.cc.size() + message.bcc.size())));
+    return result;
+}
+
+
+// A node that hands JSON.parse something that is not JSON gets QuickJS's own
+// "SyntaxError: unexpected token: '<'", which names neither the value nor where
+// it came from. Since almost every such value is a server's XML or HTML reply,
+// the parser is wrapped once for every node so the failure describes what
+// arrived instead of only which character stopped it.
+static const char* kJsonParsePrelude = R"JS(
+(function () {
+    var originalParse = JSON.parse;
+    JSON.parse = function (text, reviver) {
+        try {
+            return originalParse(text, reviver);
+        } catch (err) {
+            var raw = typeof text === 'string' ? text : String(text);
+            var trimmed = raw.replace(/^\s+/, '');
+            var kind = '';
+            if (trimmed.indexOf('<!DOCTYPE') === 0 || trimmed.indexOf('<html') === 0) {
+                kind = ' The value is an HTML page, which usually means the request was redirected, rejected or sent to the wrong address.';
+            } else if (trimmed.indexOf('<') === 0) {
+                kind = ' The value is XML, not JSON.';
+            } else if (raw.length === 0) {
+                kind = ' The value is empty.';
+            }
+            var snippet = raw.substring(0, 200).replace(/\s+/g, ' ');
+            throw new SyntaxError('JSON.parse failed: ' + err.message + '.' + kind +
+                ' Received ' + raw.length + ' characters starting: ' + snippet);
+        }
+    };
+})();
+)JS";
+
+// Setup SMTP API on smartbotic namespace
+static void setupSmtpAPI(JSContext* ctx, JSValue smartbotic) {
+    JSValue smtp_obj = JS_NewObject(ctx);
+    JS_SetPropertyStr(ctx, smtp_obj, "send", JS_NewCFunction(ctx, js_smtp_send, "send", 1));
+    JS_SetPropertyStr(ctx, smartbotic, "smtp", smtp_obj);
+}
+
 // Setup credentials API on smartbotic namespace
 static void setupCredentialsAPI(JSContext* ctx, JSValue smartbotic) {
     JSValue credentials = JS_NewObject(ctx);
@@ -4032,6 +4210,7 @@ ScriptResult ScriptEngine::execute(const std::string& script, const ScriptContex
         setupStorageAPI(context_, smartbotic);
         setupCredentialsAPI(context_, smartbotic);
         setupImapAPI(context_, smartbotic);
+        setupSmtpAPI(context_, smartbotic);
 #ifdef MYSQL_SUPPORT
         setupMysqlAPI(context_, smartbotic);
 #endif
@@ -4041,6 +4220,19 @@ ScriptResult ScriptEngine::execute(const std::string& script, const ScriptContex
     }
     JS_FreeValue(context_, smartbotic);
 
+    JSValue prelude = JS_Eval(context_, kJsonParsePrelude, strlen(kJsonParsePrelude),
+                              "<prelude>", JS_EVAL_TYPE_GLOBAL);
+    if (JS_IsException(prelude)) {
+        // The prelude only improves reporting, so a failure to install it must
+        // not stop the node from running.
+        JSValue err = JS_GetException(context_);
+        const char* err_str = JS_ToCString(context_, err);
+        LOG_WARN("Could not install the JSON.parse prelude: {}", err_str ? err_str : "unknown error");
+        if (err_str) JS_FreeCString(context_, err_str);
+        JS_FreeValue(context_, err);
+    }
+    JS_FreeValue(context_, prelude);
+
     // Set up module.exports for CommonJS compatibility
     JSValue module_obj = JS_NewObject(context_);
     JSValue exports_obj = JS_NewObject(context_);
@@ -4151,9 +4343,24 @@ ScriptResult ScriptEngine::execute(const std::string& script, const ScriptContex
         JSValue exception_str = JS_ToString(context_, exception);
         const char* str = JS_ToCString(context_, exception_str);
         result.error = str ? std::string("Execute error: ") + str : "Execute function threw an exception";
-        LOG_ERROR("Execute exception: {}", result.error);
         JS_FreeCString(context_, str);
         JS_FreeValue(context_, exception_str);
+
+        // Without the stack, an error thrown deep in a node says what went wrong
+        // but not where, which is the half that makes it fixable.
+        if (JS_IsObject(exception)) {
+            JSValue stack = JS_GetPropertyStr(context_, exception, "stack");
+            if (!JS_IsUndefined(stack) && !JS_IsNull(stack)) {
+                const char* stack_str = JS_ToCString(context_, stack);
+                if (stack_str && *stack_str) {
+                    result.error += "\nStack: " + std::string(stack_str);
+                }
+                if (stack_str) JS_FreeCString(context_, stack_str);
+            }
+            JS_FreeValue(context_, stack);
+        }
+
+        LOG_ERROR("Execute exception: {}", result.error);
         JS_FreeValue(context_, exception);
         result.success = false;
     } else {
@@ -4186,6 +4393,21 @@ ScriptResult ScriptEngine::execute(const std::string& script, const ScriptContex
                 const char* str = JS_ToCString(context_, promise_result);
                 result.error = str ? str : "Promise rejected";
                 JS_FreeCString(context_, str);
+
+                // An async execute reports its failure here rather than as a
+                // thrown exception, so the stack has to be recovered here too.
+                if (JS_IsObject(promise_result)) {
+                    JSValue stack = JS_GetPropertyStr(context_, promise_result, "stack");
+                    if (!JS_IsUndefined(stack) && !JS_IsNull(stack)) {
+                        const char* stack_str = JS_ToCString(context_, stack);
+                        if (stack_str && *stack_str) {
+                            result.error += "\nStack: " + std::string(stack_str);
+                        }
+                        if (stack_str) JS_FreeCString(context_, stack_str);
+                    }
+                    JS_FreeValue(context_, stack);
+                }
+
                 JS_FreeValue(context_, promise_result);
                 result.success = false;
             } else if (state == JS_PROMISE_FULFILLED) {

+ 12 - 0
src/runner/engine/script_engine.hpp

@@ -48,6 +48,17 @@ struct ImapCredential {
     bool use_ssl = true;
 };
 
+// SMTP credential data for JS API
+struct SmtpCredential {
+    std::string host;
+    int port = 587;
+    std::string username;
+    std::string password;
+    std::string security = "starttls";
+    std::string from_address;
+    std::string from_name;
+};
+
 // MySQL credential data for JS API
 struct MysqlCredential {
     std::string host;
@@ -160,6 +171,7 @@ struct ScriptContext {
     // Credential operations
     std::function<common::Result<CredentialAuth>(const std::string&)> credentials_get;
     std::function<common::Result<ImapCredential>(const std::string&)> credentials_get_imap;
+    std::function<common::Result<SmtpCredential>(const std::string&)> credentials_get_smtp;
     std::function<common::Result<MysqlCredential>(const std::string&)> credentials_get_mysql;
     std::function<common::Result<PostgresqlCredential>(const std::string&)> credentials_get_postgresql;
 

+ 19 - 0
src/runner/runner_service.cpp

@@ -360,6 +360,25 @@ RunnerService::RunnerService(const RunnerServiceConfig& config)
             return cred;
         });
 
+    // Set up SMTP credential callback for workflow engine
+    engine_->setSmtpCredentialCallback(
+        [this](const std::string& credential_id, const std::string& workflow_id)
+            -> common::Result<engine::SmtpCredential> {
+            auto result = credential_client_->getSmtpCredentials(credential_id, workflow_id);
+            if (result.failed()) {
+                return result.error();
+            }
+            engine::SmtpCredential cred;
+            cred.host = result.value().host;
+            cred.port = result.value().port;
+            cred.username = result.value().username;
+            cred.password = result.value().password;
+            cred.security = result.value().security;
+            cred.from_address = result.value().from_address;
+            cred.from_name = result.value().from_name;
+            return cred;
+        });
+
 #ifdef MYSQL_SUPPORT
     // Set up MySQL credential callback for workflow engine
     engine_->setMysqlCredentialCallback(

+ 194 - 0
src/runner/smtp/smtp_client.cpp

@@ -0,0 +1,194 @@
+#include "smtp_client.hpp"
+
+#include <curl/curl.h>
+
+#include <chrono>
+#include <cstring>
+#include <sstream>
+
+#include "common/uuid.hpp"
+#include "logging/logger.hpp"
+
+namespace smartbotic::runner::smtp {
+
+using namespace common;
+
+namespace {
+
+// libcurl reads the message by calling back for chunks, so the assembled MIME
+// document is handed out a piece at a time.
+struct Reader {
+    const std::string* payload;
+    size_t offset = 0;
+};
+
+size_t readPayload(char* buffer, size_t size, size_t nitems, void* userdata) {
+    auto* reader = static_cast<Reader*>(userdata);
+    const size_t room = size * nitems;
+    if (room == 0 || reader->offset >= reader->payload->size()) {
+        return 0;
+    }
+
+    const size_t remaining = reader->payload->size() - reader->offset;
+    const size_t count = remaining < room ? remaining : room;
+    std::memcpy(buffer, reader->payload->data() + reader->offset, count);
+    reader->offset += count;
+    return count;
+}
+
+std::string joinAddresses(const std::vector<std::string>& addresses) {
+    std::string joined;
+    for (size_t i = 0; i < addresses.size(); ++i) {
+        if (i > 0) joined += ", ";
+        joined += addresses[i];
+    }
+    return joined;
+}
+
+std::string rfc2822Date() {
+    const auto now = std::chrono::system_clock::to_time_t(std::chrono::system_clock::now());
+    std::tm tm{};
+    gmtime_r(&now, &tm);
+    char buffer[64];
+    std::strftime(buffer, sizeof(buffer), "%a, %d %b %Y %H:%M:%S +0000", &tm);
+    return buffer;
+}
+
+} // namespace
+
+SmtpClient::SmtpClient(SmtpCredentials credentials)
+    : credentials_(std::move(credentials)) {}
+
+std::string SmtpClient::senderAddress() const {
+    return credentials_.from_address.empty() ? credentials_.username : credentials_.from_address;
+}
+
+std::string SmtpClient::buildMime(const Message& message, const std::string& message_id) const {
+    const std::string sender = senderAddress();
+    const std::string boundary = "smartbotic-" + UUID::generate();
+
+    std::ostringstream mime;
+    mime << "Date: " << rfc2822Date() << "\r\n";
+    if (credentials_.from_name.empty()) {
+        mime << "From: " << sender << "\r\n";
+    } else {
+        mime << "From: \"" << credentials_.from_name << "\" <" << sender << ">\r\n";
+    }
+    mime << "To: " << joinAddresses(message.to) << "\r\n";
+    if (!message.cc.empty()) {
+        mime << "Cc: " << joinAddresses(message.cc) << "\r\n";
+    }
+    if (!message.reply_to.empty()) {
+        mime << "Reply-To: " << message.reply_to << "\r\n";
+    }
+    mime << "Subject: " << message.subject << "\r\n";
+    mime << "Message-ID: <" << message_id << ">\r\n";
+    mime << "MIME-Version: 1.0\r\n";
+
+    const std::string body_type = message.html ? "text/html" : "text/plain";
+
+    if (message.attachments.empty()) {
+        mime << "Content-Type: " << body_type << "; charset=UTF-8\r\n\r\n";
+        mime << message.body << "\r\n";
+        return mime.str();
+    }
+
+    mime << "Content-Type: multipart/mixed; boundary=\"" << boundary << "\"\r\n\r\n";
+    mime << "--" << boundary << "\r\n";
+    mime << "Content-Type: " << body_type << "; charset=UTF-8\r\n\r\n";
+    mime << message.body << "\r\n";
+
+    for (const auto& attachment : message.attachments) {
+        const std::string type = attachment.mime_type.empty() ? "application/octet-stream"
+                                                              : attachment.mime_type;
+        mime << "--" << boundary << "\r\n";
+        mime << "Content-Type: " << type << "; name=\"" << attachment.filename << "\"\r\n";
+        mime << "Content-Transfer-Encoding: base64\r\n";
+        mime << "Content-Disposition: attachment; filename=\"" << attachment.filename << "\"\r\n\r\n";
+
+        // Already base64 from the caller; wrapped so no line exceeds what SMTP allows.
+        const std::string& encoded = attachment.content_base64;
+        for (size_t i = 0; i < encoded.size(); i += 76) {
+            mime << encoded.substr(i, 76) << "\r\n";
+        }
+    }
+
+    mime << "--" << boundary << "--\r\n";
+    return mime.str();
+}
+
+Result<SendResult> SmtpClient::send(const Message& message) {
+    if (credentials_.host.empty()) {
+        return Error(ErrorCode::InvalidArgument, "SMTP host is required");
+    }
+    if (message.to.empty()) {
+        return Error(ErrorCode::InvalidArgument, "At least one recipient is required");
+    }
+
+    CURL* curl = curl_easy_init();
+    if (!curl) {
+        return Error(ErrorCode::Internal, "Failed to initialise curl for SMTP");
+    }
+
+    const bool implicit_tls = credentials_.security == "ssl";
+    const std::string scheme = implicit_tls ? "smtps://" : "smtp://";
+    const std::string url = scheme + credentials_.host + ":" + std::to_string(credentials_.port);
+
+    const std::string message_id = UUID::generate() + "@smartbotic";
+    const std::string payload = buildMime(message, message_id);
+    Reader reader{&payload, 0};
+
+    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
+    if (!credentials_.username.empty()) {
+        curl_easy_setopt(curl, CURLOPT_USERNAME, credentials_.username.c_str());
+        curl_easy_setopt(curl, CURLOPT_PASSWORD, credentials_.password.c_str());
+    }
+
+    if (implicit_tls) {
+        curl_easy_setopt(curl, CURLOPT_USE_SSL, CURLUSESSL_ALL);
+    } else if (credentials_.security == "starttls") {
+        // Upgrade is required rather than optional: silently sending credentials
+        // in the clear because the server did not offer STARTTLS is worse than
+        // failing to send.
+        curl_easy_setopt(curl, CURLOPT_USE_SSL, CURLUSESSL_ALL);
+    }
+
+    if (credentials_.security != "none") {
+        curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
+        curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
+    }
+
+    const std::string sender = senderAddress();
+    curl_easy_setopt(curl, CURLOPT_MAIL_FROM, sender.c_str());
+
+    struct curl_slist* recipients = nullptr;
+    for (const auto& address : message.to) recipients = curl_slist_append(recipients, address.c_str());
+    for (const auto& address : message.cc) recipients = curl_slist_append(recipients, address.c_str());
+    for (const auto& address : message.bcc) recipients = curl_slist_append(recipients, address.c_str());
+    curl_easy_setopt(curl, CURLOPT_MAIL_RCPT, recipients);
+
+    curl_easy_setopt(curl, CURLOPT_READFUNCTION, readPayload);
+    curl_easy_setopt(curl, CURLOPT_READDATA, &reader);
+    curl_easy_setopt(curl, CURLOPT_UPLOAD, 1L);
+    curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L);
+
+    const CURLcode res = curl_easy_perform(curl);
+
+    curl_slist_free_all(recipients);
+    curl_easy_cleanup(curl);
+
+    if (res != CURLE_OK) {
+        return Error(ErrorCode::Internal,
+                     std::string("SMTP send failed: ") + curl_easy_strerror(res));
+    }
+
+    LOG_INFO("SMTP: sent '{}' to {} recipient(s)", message.subject,
+             message.to.size() + message.cc.size() + message.bcc.size());
+
+    SendResult result;
+    result.success = true;
+    result.message_id = message_id;
+    return result;
+}
+
+} // namespace smartbotic::runner::smtp

+ 57 - 0
src/runner/smtp/smtp_client.hpp

@@ -0,0 +1,57 @@
+#pragma once
+
+#include <string>
+#include <vector>
+#include <common/error.hpp>
+
+namespace smartbotic::runner::smtp {
+
+struct SmtpCredentials {
+    std::string host;
+    int port = 587;
+    std::string username;
+    std::string password;
+    std::string security = "starttls";  // "starttls", "ssl" or "none"
+    std::string from_address;           // falls back to username when empty
+    std::string from_name;
+};
+
+struct Attachment {
+    std::string filename;
+    std::string mime_type;
+    std::string content_base64;
+};
+
+struct Message {
+    std::vector<std::string> to;
+    std::vector<std::string> cc;
+    std::vector<std::string> bcc;
+    std::string subject;
+    std::string body;
+    bool html = false;
+    std::string reply_to;
+    std::vector<Attachment> attachments;
+};
+
+struct SendResult {
+    bool success = false;
+    std::string message_id;
+    std::string error;
+};
+
+// Sends mail over SMTP. Uses libcurl, which already backs the IMAP client, so
+// the two share transport behaviour and TLS handling.
+class SmtpClient {
+public:
+    explicit SmtpClient(SmtpCredentials credentials);
+
+    common::Result<SendResult> send(const Message& message);
+
+private:
+    std::string buildMime(const Message& message, const std::string& message_id) const;
+    std::string senderAddress() const;
+
+    SmtpCredentials credentials_;
+};
+
+} // namespace smartbotic::runner::smtp

+ 146 - 22
src/runner/workflow_engine.cpp

@@ -69,6 +69,18 @@ Workflow Workflow::fromJson(const nlohmann::json& j) {
 
 namespace {
 
+// The editor hashes a node's config with JSON.stringify, which writes keys in
+// the order they were inserted, while nlohmann writes them sorted. Comparing
+// the two as text calls two identical configs different and re-runs a node that
+// did not change, so both sides are compared as parsed JSON instead.
+static bool configMatchesHash(const std::string& hashed, const nlohmann::json& config) {
+    try {
+        return nlohmann::json::parse(hashed) == config;
+    } catch (const std::exception&) {
+        return hashed == config.dump();
+    }
+}
+
 // Execution records are for observability, not data transport. A node that
 // carries binary content - a fetched image, a generated one - emits base64 that
 // is megabytes wide, and persisting every such output has pushed multi-megabyte
@@ -287,6 +299,35 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
                      execution_order.size(), target_node_id);
         }
 
+        // A node being tested from the editor may sit inside a loop body. The
+        // loop node then has to run so the body runs at all, which means its own
+        // recorded output must not stand in for it.
+        std::unordered_set<std::string> loop_body_members;
+        if (!target_node_id.empty()) {
+            for (const auto& node : workflow.nodes) {
+                if (node.type != "loop") continue;
+
+                std::queue<std::string> to_visit;
+                for (const auto& conn : workflow.connections) {
+                    if (conn.source_node_id == node.id && conn.source_output == "loop") {
+                        to_visit.push(conn.target_node_id);
+                    }
+                }
+                while (!to_visit.empty()) {
+                    std::string current = to_visit.front();
+                    to_visit.pop();
+                    if (current == node.id || loop_body_members.contains(current)) continue;
+                    loop_body_members.insert(current);
+                    for (const auto& conn : workflow.connections) {
+                        if (conn.source_node_id == current && conn.target_node_id != node.id) {
+                            to_visit.push(conn.target_node_id);
+                        }
+                    }
+                }
+            }
+        }
+        const bool target_inside_loop = loop_body_members.contains(target_node_id);
+
         // Find trigger node - use specified trigger if provided, otherwise first trigger
         std::string trigger_node_id;
         if (!specified_trigger_id.empty()) {
@@ -415,10 +456,14 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
                 if (cache_entry.contains("configHash") && cache_entry.contains("output")) {
                     // Compare config hash - if unchanged, we can use cached output
                     std::string cached_hash = cache_entry["configHash"].get<std::string>();
-                    std::string current_hash = node->config.dump();
-                    if (cached_hash == current_hash) {
+                    if (configMatchesHash(cached_hash, node->config)) {
                         use_cached = true;
                         LOG_INFO("Using cached output for node {} (config unchanged)", node_id);
+
+                        if (node->type == "loop" && target_inside_loop) {
+                            use_cached = false;
+                            LOG_INFO("Node {} is the loop holding the node under test, so it runs", node_id);
+                        }
                     } else {
                         LOG_DEBUG("Node {} config changed, re-executing", node_id);
                     }
@@ -488,7 +533,8 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
                 loop_ctx.continue_on_error = node_result.output.value("_continueOnError", true);
 
                 // Execute loop iterations
-                bool loop_success = executeLoopBody(node_id, workflow, loop_ctx, result, callback);
+                bool loop_success = executeLoopBody(node_id, workflow, loop_ctx, result, callback,
+                                                    target_node_id, cached_outputs);
 
                 // Update loop node result with collected results
                 auto& loop_result = result.node_results[node_id];
@@ -972,6 +1018,16 @@ NodeExecutionResult WorkflowEngine::executeNode(const WorkflowNode& node,
         return imap_credential_callback_(credential_id, workflow.id);
     };
 
+    // SMTP Credential API callback
+    ctx.credentials_get_smtp = [this, &workflow](const std::string& credential_id)
+        -> common::Result<engine::SmtpCredential> {
+        if (!smtp_credential_callback_) {
+            return common::Error(common::ErrorCode::Unavailable,
+                "SMTP Credentials API not configured");
+        }
+        return smtp_credential_callback_(credential_id, workflow.id);
+    };
+
     // MySQL Credential API callback
     ctx.credentials_get_mysql = [this, &workflow](const std::string& credential_id)
         -> common::Result<engine::MysqlCredential> {
@@ -1182,7 +1238,9 @@ bool WorkflowEngine::executeLoopBody(
     const Workflow& workflow,
     const LoopContext& loop_ctx,
     ExecutionResult& result,
-    ExecutionCallback callback) {
+    ExecutionCallback callback,
+    const std::string& target_node_id,
+    const nlohmann::json& cached_outputs) {
 
     // Find nodes connected to "loop" output
     auto body_start_nodes = findLoopBodyNodes(loop_node_id, workflow);
@@ -1263,6 +1321,42 @@ bool WorkflowEngine::executeLoopBody(
     LoopContext ctx = loop_ctx;
     bool all_succeeded = true;
 
+    // Testing one node from the editor: the node is asked for once, so the body
+    // runs for a single item and stops at the node under test. Without this the
+    // loop would run the whole body for every item, which for a node that sends
+    // a message means one message per item instead of the one that was asked for.
+    const bool single_node_mode =
+        !target_node_id.empty() && body_nodes_set.contains(target_node_id);
+
+    if (single_node_mode) {
+        std::unordered_set<std::string> keep;
+        std::function<void(const std::string&)> collectBodyUpstream;
+        collectBodyUpstream = [&](const std::string& node_id) {
+            if (keep.contains(node_id)) return;
+            keep.insert(node_id);
+            for (const auto& conn : workflow.connections) {
+                if (conn.target_node_id == node_id &&
+                    body_nodes_set.contains(conn.source_node_id)) {
+                    collectBodyUpstream(conn.source_node_id);
+                }
+            }
+        };
+        collectBodyUpstream(target_node_id);
+
+        std::vector<std::string> trimmed;
+        for (const auto& nid : sorted_body) {
+            if (keep.contains(nid)) trimmed.push_back(nid);
+        }
+        sorted_body = trimmed;
+
+        if (ctx.items.size() > 1) {
+            ctx.items.resize(1);
+        }
+
+        LOG_INFO("Single-node mode: running the loop body once, up to node {} ({} body nodes)",
+                 target_node_id, sorted_body.size());
+    }
+
     // Log the body nodes for debugging
     LOG_INFO("Loop body contains {} nodes: ", sorted_body.size());
     for (const auto& nid : sorted_body) {
@@ -1319,6 +1413,7 @@ bool WorkflowEngine::executeLoopBody(
             nlohmann::json node_input;
             bool has_loop_input = false;
             bool has_active_upstream = false;
+            bool has_body_upstream = false;   // wired to another body node, active or not
 
             for (const auto& conn : workflow.connections) {
                 if (conn.target_node_id == body_node_id) {
@@ -1333,6 +1428,7 @@ bool WorkflowEngine::executeLoopBody(
                         has_active_upstream = true;
                     } else {
                         // Input from previous body node
+                        has_body_upstream = true;
                         auto it = iteration_results.find(conn.source_node_id);
                         if (it != iteration_results.end() &&
                             it->second.status == NodeStatus::Completed) {
@@ -1358,30 +1454,58 @@ bool WorkflowEngine::executeLoopBody(
                 }
             }
 
-            // Skip this node if it only has upstream from inactive branches
-            if (!has_loop_input && !has_active_upstream && !node_input.empty()) {
-                LOG_DEBUG("Skipping node {} - no active upstream connections", body_node_id);
+            // A node fed by other body nodes runs only when one of them actually
+            // produced something this iteration. Anything else means its branch
+            // was not taken, or the node before it was itself skipped - and the
+            // skip has to carry down the chain. Falling back to the loop item
+            // here would run the whole tail of an untaken branch on every item.
+            if (!has_loop_input && has_body_upstream && !has_active_upstream) {
+                LOG_DEBUG("Skipping node {} - every upstream body node was skipped or on an inactive branch",
+                          body_node_id);
                 continue;
             }
 
-            // Also skip if node has no input at all and requires upstream
-            if (!has_loop_input && node_input.empty()) {
-                // Check if this node has any connections - if yes, it needs input
-                bool needs_upstream = false;
-                for (const auto& conn : workflow.connections) {
-                    if (conn.target_node_id == body_node_id && conn.source_node_id != loop_node_id) {
-                        auto it = iteration_results.find(conn.source_node_id);
-                        if (it != iteration_results.end()) {
-                            needs_upstream = true;
-                            break;
-                        }
+            // A node wired to nothing upstream starts from the loop item.
+            if (!has_loop_input && !has_body_upstream && node_input.empty()) {
+                node_input = loop_input;
+            }
+
+            // An upstream body node whose config has not changed since the
+            // pinned execution is not run again - its recorded output stands in.
+            if (single_node_mode && body_node_id != target_node_id &&
+                cached_outputs.contains(body_node_id)) {
+                const auto& cache_entry = cached_outputs[body_node_id];
+                if (cache_entry.contains("configHash") && cache_entry.contains("output") &&
+                    configMatchesHash(cache_entry["configHash"].get<std::string>(), body_node->config)) {
+
+                    NodeExecutionResult cached_result;
+                    cached_result.node_id = body_node_id;
+                    cached_result.status = NodeStatus::Completed;
+                    cached_result.input = node_input;
+                    cached_result.output = cache_entry["output"];
+                    cached_result.started_at = TimeUtils::nowMs();
+                    cached_result.finished_at = cached_result.started_at;
+                    cached_result.from_cache = true;
+
+                    iteration_results[body_node_id] = cached_result;
+                    result.node_results[body_node_id + "_iter_" + std::to_string(i)] = cached_result;
+
+                    if (callback) {
+                        callback("loop.node.completed", {
+                            {"executionId", result.execution_id},
+                            {"nodeId", body_node_id},
+                            {"iteration", i},
+                            {"status", "completed"},
+                            {"output", cached_result.output},
+                            {"fromCache", true}
+                        });
                     }
-                }
-                if (needs_upstream) {
-                    LOG_DEBUG("Skipping node {} - needs upstream input but none available", body_node_id);
+
+                    LOG_INFO("Using pinned output for body node {}", body_node_id);
                     continue;
                 }
-                node_input = loop_input;
+
+                LOG_DEBUG("Body node {} config changed since the pinned run, executing it", body_node_id);
             }
 
             // Execute the body node

+ 11 - 1
src/runner/workflow_engine.hpp

@@ -122,6 +122,9 @@ using CredentialAuthCallback = std::function<common::Result<engine::CredentialAu
     const std::string& credential_id, const std::string& workflow_id)>;
 
 // IMAP credential callback type
+using SmtpCredentialCallback = std::function<common::Result<engine::SmtpCredential>(
+    const std::string&, const std::string&)>;
+
 using ImapCredentialCallback = std::function<common::Result<engine::ImapCredential>(
     const std::string& credential_id, const std::string& workflow_id)>;
 
@@ -169,6 +172,7 @@ public:
 
     // Set IMAP credential callback (called by runner service to provide IMAP credential access)
     void setImapCredentialCallback(ImapCredentialCallback callback) { imap_credential_callback_ = callback; }
+    void setSmtpCredentialCallback(SmtpCredentialCallback callback) { smtp_credential_callback_ = callback; }
 
     // Set MySQL credential callback (called by runner service to provide MySQL credential access)
     void setMysqlCredentialCallback(MysqlCredentialCallback callback) { mysql_credential_callback_ = callback; }
@@ -212,11 +216,16 @@ private:
         size_t current_index = 0;
     };
 
+    // target_node_id and cached_outputs are set only when a single node is
+    // being tested from the editor. A body node tested that way must run once
+    // with the data the editor pinned, not once per item in the loop.
     bool executeLoopBody(const std::string& loop_node_id,
                         const Workflow& workflow,
                         const LoopContext& loop_ctx,
                         ExecutionResult& result,
-                        ExecutionCallback callback);
+                        ExecutionCallback callback,
+                        const std::string& target_node_id = "",
+                        const nlohmann::json& cached_outputs = nlohmann::json::object());
 
     std::vector<std::string> findLoopBodyNodes(const std::string& loop_node_id,
                                                const Workflow& workflow);
@@ -252,6 +261,7 @@ private:
     std::unique_ptr<CollectionPermissions> collection_permissions_;
     CredentialAuthCallback credential_auth_callback_;
     ImapCredentialCallback imap_credential_callback_;
+    SmtpCredentialCallback smtp_credential_callback_;
     MysqlCredentialCallback mysql_credential_callback_;
     MysqlQueryCallback mysql_query_callback_;
     PostgresqlCredentialCallback postgresql_credential_callback_;

+ 29 - 0
src/webserver/grpc/credential_service.cpp

@@ -59,6 +59,35 @@ CredentialServiceImpl::CredentialServiceImpl(CredentialStore& credential_store)
     return ::grpc::Status::OK;
 }
 
+::grpc::Status CredentialServiceImpl::GetSmtpCredentials(
+    ::grpc::ServerContext* context,
+    const proto::GetSmtpCredentialsRequest* request,
+    proto::GetSmtpCredentialsResponse* response) {
+
+    LOG_DEBUG("GetSmtpCredentials request: credential={} workflow={}",
+              request->credential_id(), request->workflow_id());
+
+    auto result = credential_store_.getSmtpCredentials(request->credential_id(), request->workflow_id());
+
+    if (result.failed()) {
+        response->set_success(false);
+        response->set_error(result.error().message());
+        LOG_WARN("GetSmtpCredentials failed: {}", result.error().message());
+        return ::grpc::Status::OK;
+    }
+
+    response->set_success(true);
+    response->set_host(result.value().host);
+    response->set_port(result.value().port);
+    response->set_username(result.value().username);
+    response->set_password(result.value().password);
+    response->set_security(result.value().security);
+    response->set_from_address(result.value().from_address);
+    response->set_from_name(result.value().from_name);
+
+    return ::grpc::Status::OK;
+}
+
 ::grpc::Status CredentialServiceImpl::GetMysqlCredentials(
     ::grpc::ServerContext* context,
     const proto::GetMysqlCredentialsRequest* request,

+ 5 - 0
src/webserver/grpc/credential_service.hpp

@@ -24,6 +24,11 @@ public:
         const proto::GetImapCredentialsRequest* request,
         proto::GetImapCredentialsResponse* response) override;
 
+    ::grpc::Status GetSmtpCredentials(
+        ::grpc::ServerContext* context,
+        const proto::GetSmtpCredentialsRequest* request,
+        proto::GetSmtpCredentialsResponse* response) override;
+
     ::grpc::Status GetMysqlCredentials(
         ::grpc::ServerContext* context,
         const proto::GetMysqlCredentialsRequest* request,

+ 158 - 64
src/webserver/nodes/node_store.cpp

@@ -9,57 +9,157 @@ namespace smartbotic::webserver::nodes {
 
 using namespace common;
 
-// Helper function to convert JavaScript object notation to JSON
-static std::string jsObjectToJson(const std::string& jsObj) {
-    std::string result = jsObj;
-
-    // Remove JavaScript comments (single line) - only at start of line to avoid matching URLs
-    // Match // only when preceded by start of string, newline, or whitespace after comma/brace
-    std::regex comment_regex(R"((^|\n)\s*//[^\n]*)");
-    result = std::regex_replace(result, comment_regex, "$1");
-
-    // Add quotes around unquoted keys
-    std::regex unquoted_key_regex(R"((\{|,)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:)");
-    result = std::regex_replace(result, unquoted_key_regex, "$1\"$2\":");
+// A node declares its schema as a JavaScript object literal, and the values in
+// it are prose and examples: descriptions containing apostrophes, defaults
+// holding sample code with braces of their own. Scanning that as plain text
+// mistakes such punctuation for structure, which silently cost two nodes their
+// entire configuration form. So the literal is read the way JavaScript reads
+// it - strings, escapes and comments recognised - and rewritten as JSON.
+namespace {
+
+// Appends one JavaScript string literal to out as a JSON string. Reading starts
+// at the opening quote and leaves i on the closing one.
+void appendJsString(const std::string& code, size_t& i, std::string& out) {
+    const char quote = code[i];
+    out += '"';
+    ++i;
+
+    while (i < code.size() && code[i] != quote) {
+        const char c = code[i];
+
+        if (c == '\\' && i + 1 < code.size()) {
+            const char escaped = code[i + 1];
+            switch (escaped) {
+                // Escapes JSON also understands travel unchanged.
+                case '"': case '\\': case '/': case 'b':
+                case 'f': case 'n': case 'r': case 't': case 'u':
+                    out += '\\';
+                    out += escaped;
+                    break;
+                // \' is how an apostrophe is written inside a single-quoted
+                // string; JSON has no such escape and needs the bare character.
+                default:
+                    if (escaped == '"') {
+                        out += "\\\"";
+                    } else {
+                        out += escaped;
+                    }
+                    break;
+            }
+            i += 2;
+            continue;
+        }
 
-    // Replace single quotes with double quotes
-    std::regex single_quote_regex(R"('([^']*)')");
-    result = std::regex_replace(result, single_quote_regex, "\"$1\"");
+        if (c == '"') {
+            out += "\\\"";           // a double quote inside '...' or `...`
+        } else if (c == '\n') {
+            out += "\\n";            // a real newline inside a template literal
+        } else if (c == '\r') {
+            out += "\\r";
+        } else if (c == '\t') {
+            out += "\\t";
+        } else {
+            out += c;
+        }
+        ++i;
+    }
 
-    // Remove trailing commas before ] or }
-    std::regex trailing_comma_regex(R"(,(\s*[}\]]))");
-    result = std::regex_replace(result, trailing_comma_regex, "$1");
+    out += '"';
+}
 
-    return result;
+// Drops whatever trailing whitespace and one trailing comma sit at the end of
+// out, so JavaScript's permitted trailing comma does not reach JSON.
+void dropTrailingComma(std::string& out) {
+    size_t end = out.size();
+    while (end > 0 && std::isspace(static_cast<unsigned char>(out[end - 1]))) {
+        --end;
+    }
+    if (end > 0 && out[end - 1] == ',') {
+        out.erase(end - 1);
+    }
 }
 
-// Helper to extract balanced braces
-static std::string extractBalancedBraces(const std::string& code, size_t start) {
-    if (start >= code.size() || code[start] != '{') {
+} // namespace
+
+// Reads the object or array literal starting at `start` and returns it as JSON
+// text. Returns an empty string if the literal is not balanced.
+static std::string jsLiteralToJson(const std::string& code, size_t start) {
+    if (start >= code.size() || (code[start] != '{' && code[start] != '[')) {
         return "";
     }
 
+    std::string out;
     int depth = 0;
-    size_t end = start;
 
     for (size_t i = start; i < code.size(); ++i) {
-        char c = code[i];
-        if (c == '{') {
-            depth++;
-        } else if (c == '}') {
-            depth--;
+        const char c = code[i];
+
+        if (c == '\'' || c == '"' || c == '`') {
+            appendJsString(code, i, out);
+            continue;
+        }
+
+        if (c == '/' && i + 1 < code.size()) {
+            if (code[i + 1] == '/') {
+                while (i < code.size() && code[i] != '\n') ++i;
+                continue;
+            }
+            if (code[i + 1] == '*') {
+                i += 2;
+                while (i + 1 < code.size() && !(code[i] == '*' && code[i + 1] == '/')) ++i;
+                ++i;
+                continue;
+            }
+        }
+
+        if (c == '{' || c == '[') {
+            ++depth;
+            out += c;
+            continue;
+        }
+
+        if (c == '}' || c == ']') {
+            dropTrailingComma(out);
+            out += c;
+            --depth;
             if (depth == 0) {
-                end = i;
-                break;
+                return out;
             }
+            continue;
         }
-    }
 
-    if (depth != 0) {
-        return "";
+        // An unquoted key: an identifier that the next non-space character
+        // turns into a key by following it with a colon.
+        if (std::isalpha(static_cast<unsigned char>(c)) || c == '_' || c == '$') {
+            size_t end = i;
+            while (end < code.size() &&
+                   (std::isalnum(static_cast<unsigned char>(code[end])) ||
+                    code[end] == '_' || code[end] == '$')) {
+                ++end;
+            }
+            const std::string word = code.substr(i, end - i);
+
+            size_t after = end;
+            while (after < code.size() &&
+                   std::isspace(static_cast<unsigned char>(code[after]))) {
+                ++after;
+            }
+
+            const bool is_key = after < code.size() && code[after] == ':';
+            if (is_key) {
+                out += '"' + word + '"';
+            } else {
+                out += word;    // true, false, null and anything else as written
+            }
+
+            i = end - 1;
+            continue;
+        }
+
+        out += c;
     }
 
-    return code.substr(start, end - start + 1);
+    return "";   // ran off the end without closing
 }
 
 // NodeIO implementation
@@ -204,13 +304,12 @@ StoredNode StoredNode::parseFromCode(const std::string& code, const std::string&
         size_t start_pos = match.position() + match.length();
         size_t brace_pos = code.find('{', start_pos);
         if (brace_pos != std::string::npos) {
-            std::string jsObj = extractBalancedBraces(code, brace_pos);
-            if (!jsObj.empty()) {
-                std::string jsonStr = jsObjectToJson(jsObj);
+            std::string jsonStr = jsLiteralToJson(code, brace_pos);
+            if (!jsonStr.empty()) {
                 try {
                     node.config_schema = nlohmann::json::parse(jsonStr);
                 } catch (const nlohmann::json::exception& e) {
-                    LOG_DEBUG("Failed to parse configSchema for {}: {}", node.id, e.what());
+                    LOG_WARN("Failed to parse configSchema for {}: {}", node.id, e.what());
                 }
             }
         }
@@ -222,13 +321,12 @@ StoredNode StoredNode::parseFromCode(const std::string& code, const std::string&
         size_t start_pos = match.position() + match.length();
         size_t brace_pos = code.find('{', start_pos);
         if (brace_pos != std::string::npos) {
-            std::string jsObj = extractBalancedBraces(code, brace_pos);
-            if (!jsObj.empty()) {
-                std::string jsonStr = jsObjectToJson(jsObj);
+            std::string jsonStr = jsLiteralToJson(code, brace_pos);
+            if (!jsonStr.empty()) {
                 try {
                     node.input_schema = nlohmann::json::parse(jsonStr);
                 } catch (const nlohmann::json::exception& e) {
-                    LOG_DEBUG("Failed to parse inputSchema for {}: {}", node.id, e.what());
+                    LOG_WARN("Failed to parse inputSchema for {}: {}", node.id, e.what());
                 }
             }
         }
@@ -240,13 +338,12 @@ StoredNode StoredNode::parseFromCode(const std::string& code, const std::string&
         size_t start_pos = match.position() + match.length();
         size_t brace_pos = code.find('{', start_pos);
         if (brace_pos != std::string::npos) {
-            std::string jsObj = extractBalancedBraces(code, brace_pos);
-            if (!jsObj.empty()) {
-                std::string jsonStr = jsObjectToJson(jsObj);
+            std::string jsonStr = jsLiteralToJson(code, brace_pos);
+            if (!jsonStr.empty()) {
                 try {
                     node.output_schema = nlohmann::json::parse(jsonStr);
                 } catch (const nlohmann::json::exception& e) {
-                    LOG_DEBUG("Failed to parse outputSchema for {}: {}", node.id, e.what());
+                    LOG_WARN("Failed to parse outputSchema for {}: {}", node.id, e.what());
                 }
             }
         }
@@ -256,21 +353,8 @@ StoredNode StoredNode::parseFromCode(const std::string& code, const std::string&
     std::regex outputs_start_regex(R"(const\s+outputs\s*=\s*\[)");
     if (std::regex_search(code, match, outputs_start_regex)) {
         size_t start_pos = match.position() + match.length() - 1;
-        int depth = 0;
-        size_t end_pos = start_pos;
-        for (size_t i = start_pos; i < code.size(); ++i) {
-            if (code[i] == '[') depth++;
-            else if (code[i] == ']') {
-                depth--;
-                if (depth == 0) {
-                    end_pos = i;
-                    break;
-                }
-            }
-        }
-        if (depth == 0 && end_pos > start_pos) {
-            std::string jsArray = code.substr(start_pos, end_pos - start_pos + 1);
-            std::string jsonStr = jsObjectToJson(jsArray);
+        std::string jsonStr = jsLiteralToJson(code, start_pos);
+        if (!jsonStr.empty()) {
             try {
                 auto outputs_json = nlohmann::json::parse(jsonStr);
                 if (outputs_json.is_array()) {
@@ -449,11 +533,21 @@ Result<std::vector<StoredNode>> NodeStore::migrateFromFiles(const std::filesyste
         // Check if already exists
         auto existing = get(node.id);
         if (existing.ok()) {
-            // Check if code has changed - update if different
-            if (existing.value().code != code) {
+            // A stored definition can also fall behind when the file is
+            // untouched but the parser that read it has improved. Comparing
+            // what was stored against a fresh parse catches that, so a fixed
+            // parser reaches nodes that were imported by the broken one.
+            const bool code_changed = existing.value().code != code;
+            const bool schema_changed =
+                existing.value().config_schema != node.config_schema ||
+                existing.value().input_schema != node.input_schema ||
+                existing.value().output_schema != node.output_schema;
+
+            if (code_changed || schema_changed) {
                 auto update_result = update(node.id, code);
                 if (update_result.ok()) {
-                    LOG_INFO("Updated node from file: {}", node.id);
+                    LOG_INFO("Updated node from file: {} ({})", node.id,
+                             code_changed ? "code changed" : "re-parsed schema");
                     migrated_nodes.push_back(update_result.value());
                 } else {
                     LOG_WARN("Failed to update node {}: {}", node.id, update_result.error().message());

+ 1 - 1
webui/src/api/credentials.ts

@@ -1,6 +1,6 @@
 import { api } from './client'
 
-export type CredentialType = 'basic' | 'bearer' | 'api_key' | 'oauth2' | 'imap' | 'mysql' | 'postgresql'
+export type CredentialType = 'basic' | 'bearer' | 'api_key' | 'oauth2' | 'imap' | 'smtp' | 'mysql' | 'postgresql'
 
 export interface CredentialInfo {
   id: string

+ 112 - 6
webui/src/components/ExpressionInput.tsx

@@ -16,9 +16,62 @@ interface ExpressionInputProps {
   placeholder?: string
   availableFields?: AvailableField[]
   multiline?: boolean
+  // Set for fields that hold source code, which want a monospace face and room
+  // to work in whatever their current contents happen to look like.
+  codeMode?: boolean
   className?: string
 }
 
+type ContentKind = 'plain' | 'json' | 'broken-json' | 'markdown'
+
+// What someone typed into a field says what it is. A prompt, a JSON body and a
+// note are all "string" to the schema, so the shape of the text is the only
+// signal available - and it is a reliable one.
+function detectContentKind(value: string): ContentKind {
+  const text = value.trim()
+  if (!text) return 'plain'
+
+  const looksStructured = /^[[{]/.test(text) && /[\]}]$/.test(text)
+  if (looksStructured) {
+    try {
+      JSON.parse(text)
+      return 'json'
+    } catch {
+      return 'broken-json'
+    }
+  }
+
+  // Two or more markdown constructs, so a lone hyphen or a URL in prose does
+  // not get called markdown.
+  const markers = [
+    /^#{1,6}\s+\S/m,        // heading
+    /^\s*[-*+]\s+\S/m,      // bullet list
+    /^\s*\d+\.\s+\S/m,     // numbered list
+    /\*\*[^*\n]+\*\*/,      // bold
+    /\[[^\]\n]+\]\([^)\n]+\)/, // link
+    /^>\s+\S/m,            // quote
+    /```/,                  // fenced code
+  ].filter((pattern) => pattern.test(text)).length
+
+  return markers >= 2 ? 'markdown' : 'plain'
+}
+
+const KIND_LABELS: Record<ContentKind, { label: string; className: string } | null> = {
+  plain: null,
+  json: {
+    label: 'JSON',
+    className: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
+  },
+  'broken-json': {
+    label: 'JSON with a syntax error',
+    className: 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300',
+  },
+  markdown: {
+    label: 'Markdown',
+    className: 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300',
+  },
+}
+
 const TYPE_COLORS: Record<string, string> = {
   string: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300',
   number: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300',
@@ -34,6 +87,7 @@ export function ExpressionInput({
   placeholder,
   availableFields = [],
   multiline = false,
+  codeMode = false,
   className = '',
 }: ExpressionInputProps) {
   const [showAutocomplete, setShowAutocomplete] = useState(false)
@@ -209,30 +263,51 @@ export function ExpressionInput({
     }, 0)
   }
 
-  const InputComponent = multiline ? 'textarea' : 'input'
+  const kind = detectContentKind(value || '')
+  const kindLabel = KIND_LABELS[kind]
+  const isCode = codeMode || kind === 'json' || kind === 'broken-json'
+
+  // The field is always a textarea so it can grow with what is typed into it
+  // without swapping the element under the cursor, which would lose focus and
+  // the caret mid-sentence.
+  useEffect(() => {
+    const el = inputRef.current as HTMLTextAreaElement | null
+    if (!el) return
+    el.style.height = 'auto'
+    const minHeight = codeMode ? 200 : multiline ? 80 : 38
+    el.style.height = Math.min(Math.max(el.scrollHeight, minHeight), 420) + 'px'
+  }, [value, multiline, codeMode])
+
+  const formatJson = () => {
+    try {
+      onChange(JSON.stringify(JSON.parse(value), null, 2))
+    } catch {
+      // Only offered when the value parses, so there is nothing to report here.
+    }
+  }
 
   return (
     <div className={`relative ${className}`}>
       <div className="flex items-stretch">
         <div className="relative flex-1">
-          <InputComponent
+          <textarea
             ref={inputRef as any}
             value={value}
             onChange={handleChange}
             onSelect={handleSelect}
             onKeyDown={handleKeyDown}
             placeholder={placeholder || 'Enter value or use {{ }} for expressions'}
-            className={`w-full px-3 py-2 pr-10 border border-gray-200 dark:border-slate-600 rounded-l-lg text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
-              multiline ? 'min-h-[80px] resize-y' : ''
+            className={`w-full px-3 py-2 pr-10 border border-gray-200 dark:border-slate-600 rounded-l-lg text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y overflow-hidden ${
+              isCode ? 'font-mono text-xs' : ''
             }`}
-            rows={multiline ? 3 : undefined}
+            rows={1}
           />
 
           {/* Clear button */}
           {value && (
             <button
               onClick={() => onChange('')}
-              className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
+              className="absolute right-2 top-2 p-1 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
               title="Clear"
             >
               <X className="w-4 h-4" />
@@ -292,6 +367,37 @@ export function ExpressionInput({
         </div>
       )}
 
+      {/* What the field currently holds, and the one action that content wants */}
+      {kindLabel && (
+        <div className="flex items-center gap-2 mt-1 text-xs">
+          <span className={`px-1.5 py-0.5 rounded font-medium ${kindLabel.className}`}>
+            {kindLabel.label}
+          </span>
+          {kind === 'json' && (
+            <button
+              onClick={formatJson}
+              className="text-primary-600 dark:text-primary-400 hover:underline"
+              title="Re-indent this JSON"
+            >
+              Format
+            </button>
+          )}
+          {kind === 'broken-json' && (
+            <span className="text-gray-500 dark:text-gray-400">
+              Starts and ends like JSON but does not parse - check quotes and commas
+            </span>
+          )}
+          {kind === 'markdown' && (
+            <span className="text-gray-500 dark:text-gray-400">
+              <code className="bg-gray-100 dark:bg-slate-700 px-1 rounded">**bold**</code>{' '}
+              <code className="bg-gray-100 dark:bg-slate-700 px-1 rounded">- list</code>{' '}
+              <code className="bg-gray-100 dark:bg-slate-700 px-1 rounded"># heading</code>{' '}
+              <code className="bg-gray-100 dark:bg-slate-700 px-1 rounded">[text](url)</code>
+            </span>
+          )}
+        </div>
+      )}
+
       {/* Expression syntax help */}
       {showAutocomplete && filteredFields.length === 0 && (
         <div

+ 9 - 2
webui/src/components/workflow/NodeConfigModal.tsx

@@ -359,9 +359,14 @@ export function NodeConfigModal({
                         <option value="">Select credential...</option>
                         {credentials
                           .filter(cred => {
-                            // Filter by credential type if specified
+                            // Filter by credential type if specified. A node that
+                            // works with more than one type lists them all, so an
+                            // account stored once can serve both.
                             const typeFilter = prop.dynamicOptions?.filter?.type
-                            return !typeFilter || cred.type === typeFilter
+                            if (!typeFilter) return true
+                            return Array.isArray(typeFilter)
+                              ? typeFilter.includes(cred.type)
+                              : cred.type === typeFilter
                           })
                           .map((cred) => (
                             <option key={cred.id} value={cred.id}>
@@ -424,6 +429,8 @@ export function NodeConfigModal({
                         onChange={(value) =>
                           onConfigChange({ ...editingConfig, [key]: value })
                         }
+                        multiline={prop.format === 'code' || prop.format === 'textarea'}
+                        codeMode={prop.format === 'code'}
                         placeholder={prop.description}
                         availableFields={getUpstreamOutputFields(selectedNodeData.id).map((f) => ({
                           path: f.path,

+ 114 - 0
webui/src/pages/CredentialsPage.tsx

@@ -70,6 +70,11 @@ const CREDENTIAL_TYPE_INFO: Record<
     icon: Mail,
     description: 'IMAP email server authentication',
   },
+  smtp: {
+    label: 'SMTP',
+    icon: Mail,
+    description: 'Outgoing mail server. An IMAP credential also works where a node sends from a mailbox it reads.',
+  },
   mysql: {
     label: 'MySQL',
     icon: Database,
@@ -368,6 +373,14 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
   const [imapPassword, setImapPassword] = useState('')
   const [imapUseSsl, setImapUseSsl] = useState((pd.use_ssl as boolean) ?? true)
 
+  const [smtpHost, setSmtpHost] = useState((pd.host as string) || '')
+  const [smtpPort, setSmtpPort] = useState((pd.port as number) || 587)
+  const [smtpUsername, setSmtpUsername] = useState((pd.username as string) || '')
+  const [smtpPassword, setSmtpPassword] = useState('')
+  const [smtpSecurity, setSmtpSecurity] = useState((pd.security as string) || 'starttls')
+  const [smtpFromAddress, setSmtpFromAddress] = useState((pd.from_address as string) || '')
+  const [smtpFromName, setSmtpFromName] = useState((pd.from_name as string) || '')
+
   // MySQL fields
   const [mysqlHost, setMysqlHost] = useState((pd.host as string) || '')
   const [mysqlPort, setMysqlPort] = useState((pd.port as number) || 3306)
@@ -430,6 +443,16 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
           password: imapPassword,
           use_ssl: imapUseSsl,
         }
+      case 'smtp':
+        return {
+          host: smtpHost,
+          port: smtpPort,
+          username: smtpUsername,
+          password: smtpPassword,
+          security: smtpSecurity,
+          from_address: smtpFromAddress,
+          from_name: smtpFromName,
+        }
       case 'mysql':
         return {
           host: mysqlHost,
@@ -816,6 +839,97 @@ function CredentialModal({ credential, onClose, onSuccess }: CredentialModalProp
               </>
             )}
 
+            {type === 'smtp' && (
+              <>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    SMTP Host *
+                  </label>
+                  <input
+                    type="text"
+                    value={smtpHost}
+                    onChange={(e) => setSmtpHost(e.target.value)}
+                    placeholder="smtp.example.com"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Port
+                  </label>
+                  <input
+                    type="number"
+                    value={smtpPort}
+                    onChange={(e) => setSmtpPort(parseInt(e.target.value) || 587)}
+                    placeholder="587"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Encryption
+                  </label>
+                  <select
+                    value={smtpSecurity}
+                    onChange={(e) => setSmtpSecurity(e.target.value)}
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  >
+                    <option value="starttls">STARTTLS (port 587)</option>
+                    <option value="ssl">SSL/TLS (port 465)</option>
+                    <option value="none">None (port 25)</option>
+                  </select>
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Username *
+                  </label>
+                  <input
+                    type="text"
+                    value={smtpUsername}
+                    onChange={(e) => setSmtpUsername(e.target.value)}
+                    placeholder="user@example.com"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Password *
+                  </label>
+                  <input
+                    type={showSecrets ? 'text' : 'password'}
+                    value={smtpPassword}
+                    onChange={(e) => setSmtpPassword(e.target.value)}
+                    placeholder={credential ? '(unchanged)' : 'Enter password'}
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Sender address
+                  </label>
+                  <input
+                    type="text"
+                    value={smtpFromAddress}
+                    onChange={(e) => setSmtpFromAddress(e.target.value)}
+                    placeholder="Defaults to the username"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                    Sender name
+                  </label>
+                  <input
+                    type="text"
+                    value={smtpFromName}
+                    onChange={(e) => setSmtpFromName(e.target.value)}
+                    placeholder="Shown to recipients alongside the address"
+                    className="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+                  />
+                </div>
+              </>
+            )}
+
             {type === 'mysql' && (
               <>
                 <div>

+ 6 - 3
webui/src/pages/WorkflowEditorPage.tsx

@@ -533,9 +533,12 @@ function WorkflowEditorInner() {
 
     const upstreamNodeIds = collectUpstreamNodes(targetNodeId)
 
-    // For each upstream node, if we have cached output, include it
+    // For each upstream node, if we have cached output, include it. Data pinned
+    // from an earlier execution counts as cached: pinning it is how you say
+    // "use this instead of running the workflow again", and without sending it
+    // every upstream node would run for real.
     for (const nodeId of upstreamNodeIds) {
-      const cachedOutput = lastExecutionResults[nodeId]
+      const cachedOutput = lastExecutionResults[nodeId] ?? pinnedNodeData[nodeId]
       if (cachedOutput !== undefined) {
         // Find the node to get its current config
         const node = nodes.find(n => n.id === nodeId)
@@ -580,7 +583,7 @@ function WorkflowEditorInner() {
       setExecutingNodeId(null)
       showToast('error', `Execution failed: ${error.message}`)
     })
-  }, [id, showToast, nodes, edges, lastExecutionResults, hashConfig])
+  }, [id, showToast, nodes, edges, lastExecutionResults, pinnedNodeData, hashConfig])
 
   // WebSocket subscription for execution updates - subscribe on mount, filter in handler
   useEffect(() => {