From 76759c73dbb40680fa7d6ecd4f3033030995ba71 Mon Sep 17 00:00:00 2001 From: tnm Date: Mon, 2 Mar 2026 22:58:39 -0800 Subject: [PATCH] Port email bridge tools to builtin C tool registry --- main/CMakeLists.txt | 2 + main/bridge_client.c | 194 ++++++ main/bridge_client.h | 21 + main/builtin_tools.def | 14 + main/memory_keys.c | 2 + main/nvs_keys.h | 2 + main/tools_email.c | 398 ++++++++++++ main/tools_handlers.h | 3 + scripts/provision-dev.sh | 28 +- scripts/provision.sh | 38 ++ scripts/test.sh | 4 + test/host/mock_bridge_client.c | 97 +++ test/host/mock_bridge_client.h | 14 + test/host/mock_tools_common.c | 94 +++ test/host/test_install_provision_scripts.py | 124 ++++ test/host/test_memory_keys.c | 2 + test/host/test_runner.c | 2 + test/host/test_tools_email.c | 644 ++++++++++++++++++++ 18 files changed, 1682 insertions(+), 1 deletion(-) create mode 100644 main/bridge_client.c create mode 100644 main/bridge_client.h create mode 100644 main/tools_email.c create mode 100644 test/host/mock_bridge_client.c create mode 100644 test/host/mock_bridge_client.h create mode 100644 test/host/mock_tools_common.c create mode 100644 test/host/test_tools_email.c diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index aaed6f2..a398dd6 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -13,6 +13,8 @@ idf_component_register( "tools_persona.c" "tools_cron.c" "tools_system.c" + "tools_email.c" + "bridge_client.c" "memory.c" "json_util.c" "telegram.c" diff --git a/main/bridge_client.c b/main/bridge_client.c new file mode 100644 index 0000000..2ed158f --- /dev/null +++ b/main/bridge_client.c @@ -0,0 +1,194 @@ +#include "bridge_client.h" +#include "memory.h" +#include "nvs_keys.h" +#include "text_buffer.h" +#include "esp_crt_bundle.h" +#include "esp_http_client.h" +#include "esp_log.h" +#include +#include +#include + +#define BRIDGE_URL_MAX 256 +#define BRIDGE_KEY_MAX 128 +#define BRIDGE_ENDPOINT_MAX 320 +#define BRIDGE_HTTP_TIMEOUT_MS 15000 + +typedef struct { + char *buf; + size_t len; + size_t max; + bool truncated; +} bridge_client_http_ctx_t; + +static const char *TAG = "bridge_client"; + +static void normalize_bridge_url(const char *raw, char *out, size_t out_len) +{ + size_t len; + + if (!raw || !out || out_len == 0) { + return; + } + + strncpy(out, raw, out_len - 1); + out[out_len - 1] = '\0'; + + len = strlen(out); + while (len > 0 && out[len - 1] == '/') { + out[len - 1] = '\0'; + len--; + } +} + +static bool load_bridge_config(char *url_out, + size_t url_out_len, + char *key_out, + size_t key_out_len) +{ + char raw_url[BRIDGE_URL_MAX] = {0}; + + if (!memory_get(NVS_KEY_BRIDGE_URL, raw_url, sizeof(raw_url)) || raw_url[0] == '\0') { + return false; + } + if (!memory_get(NVS_KEY_BRIDGE_KEY, key_out, key_out_len) || key_out[0] == '\0') { + return false; + } + + normalize_bridge_url(raw_url, url_out, url_out_len); + return url_out[0] != '\0'; +} + +static esp_err_t bridge_client_http_event_handler(esp_http_client_event_t *evt) +{ + bridge_client_http_ctx_t *ctx = (bridge_client_http_ctx_t *)evt->user_data; + + if (!ctx) { + return ESP_OK; + } + + if (evt->event_id == HTTP_EVENT_ON_DATA && evt->data && evt->data_len > 0) { + bool ok = text_buffer_append(ctx->buf, &ctx->len, ctx->max, (const char *)evt->data, evt->data_len); + if (!ok && !ctx->truncated) { + ctx->truncated = true; + ESP_LOGW(TAG, "Bridge response truncated at %d bytes", (int)(ctx->max - 1)); + } + } + + return ESP_OK; +} + +bool bridge_client_is_configured(void) +{ + char url[BRIDGE_URL_MAX] = {0}; + char key[BRIDGE_KEY_MAX] = {0}; + return load_bridge_config(url, sizeof(url), key, sizeof(key)); +} + +esp_err_t bridge_client_post_json(const char *path, + const cJSON *payload, + char *response_out, + size_t response_out_len, + int *status_out, + bool *truncated_out) +{ + char bridge_url[BRIDGE_URL_MAX] = {0}; + char bridge_key[BRIDGE_KEY_MAX] = {0}; + char auth_header[BRIDGE_KEY_MAX + 16] = {0}; + char endpoint[BRIDGE_ENDPOINT_MAX] = {0}; + char *payload_json = NULL; + const char *payload_body = "{}"; + esp_http_client_handle_t client = NULL; + int status = -1; + esp_err_t err; + bridge_client_http_ctx_t ctx = { + .buf = response_out, + .len = 0, + .max = response_out_len, + .truncated = false, + }; + + if (!response_out || response_out_len == 0 || !path || path[0] == '\0') { + return ESP_ERR_INVALID_ARG; + } + + response_out[0] = '\0'; + if (status_out) { + *status_out = -1; + } + if (truncated_out) { + *truncated_out = false; + } + + if (!load_bridge_config(bridge_url, sizeof(bridge_url), bridge_key, sizeof(bridge_key))) { + return ESP_ERR_INVALID_STATE; + } + + if (path[0] == '/') { + if (snprintf(endpoint, sizeof(endpoint), "%s%s", bridge_url, path) >= (int)sizeof(endpoint)) { + return ESP_ERR_INVALID_SIZE; + } + } else { + if (snprintf(endpoint, sizeof(endpoint), "%s/%s", bridge_url, path) >= (int)sizeof(endpoint)) { + return ESP_ERR_INVALID_SIZE; + } + } + + if (payload) { + payload_json = cJSON_PrintUnformatted((cJSON *)payload); + if (!payload_json) { + return ESP_ERR_NO_MEM; + } + payload_body = payload_json; + } + + esp_http_client_config_t cfg = { + .url = endpoint, + .event_handler = bridge_client_http_event_handler, + .user_data = &ctx, + .timeout_ms = BRIDGE_HTTP_TIMEOUT_MS, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + + client = esp_http_client_init(&cfg); + if (!client) { + free(payload_json); + return ESP_FAIL; + } + + esp_http_client_set_method(client, HTTP_METHOD_POST); + esp_http_client_set_header(client, "Content-Type", "application/json"); + if (snprintf(auth_header, sizeof(auth_header), "Bearer %s", bridge_key) >= (int)sizeof(auth_header)) { + esp_http_client_cleanup(client); + free(payload_json); + return ESP_ERR_INVALID_SIZE; + } + esp_http_client_set_header(client, "Authorization", auth_header); + esp_http_client_set_header(client, "X-Zclaw-Bridge-Key", bridge_key); + esp_http_client_set_post_field(client, payload_body, (int)strlen(payload_body)); + + err = esp_http_client_perform(client); + status = esp_http_client_get_status_code(client); + + if (status_out) { + *status_out = status; + } + if (truncated_out) { + *truncated_out = ctx.truncated; + } + + esp_http_client_cleanup(client); + free(payload_json); + + if (ctx.truncated) { + return ESP_ERR_NO_MEM; + } + if (err != ESP_OK) { + return err; + } + if (status < 200 || status >= 300) { + return ESP_FAIL; + } + + return ESP_OK; +} diff --git a/main/bridge_client.h b/main/bridge_client.h new file mode 100644 index 0000000..a6d5887 --- /dev/null +++ b/main/bridge_client.h @@ -0,0 +1,21 @@ +#ifndef BRIDGE_CLIENT_H +#define BRIDGE_CLIENT_H + +#include "cJSON.h" +#include "esp_err.h" +#include +#include + +// Returns true when both bridge URL and key are provisioned. +bool bridge_client_is_configured(void); + +// POST JSON payload to configured bridge endpoint path (e.g. "/v1/email/send"). +// response_out always receives a null-terminated string (possibly empty). +esp_err_t bridge_client_post_json(const char *path, + const cJSON *payload, + char *response_out, + size_t response_out_len, + int *status_out, + bool *truncated_out); + +#endif // BRIDGE_CLIENT_H diff --git a/main/builtin_tools.def b/main/builtin_tools.def index ea7cc8e..8fbd324 100644 --- a/main/builtin_tools.def +++ b/main/builtin_tools.def @@ -101,6 +101,20 @@ TOOL_ENTRY("get_diagnostics", "{\"type\":\"object\",\"properties\":{\"scope\":{\"type\":\"string\",\"enum\":[\"quick\",\"runtime\",\"memory\",\"rates\",\"time\",\"all\"],\"description\":\"Optional diagnostics scope (default quick)\"},\"verbose\":{\"type\":\"boolean\",\"description\":\"Include extra details (default false)\"}}}", tools_get_diagnostics_handler) +// Email Bridge +TOOL_ENTRY("email_send", + "Send an email through the configured email bridge service. Requires bridge provisioning.", + "{\"type\":\"object\",\"properties\":{\"to\":{\"type\":\"string\",\"description\":\"Recipient email address\"},\"subject\":{\"type\":\"string\",\"description\":\"Email subject\"},\"body\":{\"type\":\"string\",\"description\":\"Plain-text email body\"}},\"required\":[\"to\",\"subject\",\"body\"]}", + tools_email_send_handler) +TOOL_ENTRY("email_list", + "List recent emails through the configured email bridge service. Supports optional unread filtering.", + "{\"type\":\"object\",\"properties\":{\"label\":{\"type\":\"string\",\"description\":\"Optional mailbox label (default INBOX)\"},\"max\":{\"type\":\"integer\",\"description\":\"Max emails to return (1-20, default 5)\"},\"unread_only\":{\"type\":\"boolean\",\"description\":\"When true, return unread email only\"}}}", + tools_email_list_handler) +TOOL_ENTRY("email_read", + "Read a specific email by message id through the configured email bridge service.", + "{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Message identifier from email_list\"},\"max_chars\":{\"type\":\"integer\",\"description\":\"Optional max body characters to return (200-4000, default 1200)\"}},\"required\":[\"id\"]}", + tools_email_read_handler) + // User Tool Management TOOL_ENTRY("create_tool", "Create a custom tool. Provide a short name (no spaces), brief description, and the action to perform when called.", diff --git a/main/memory_keys.c b/main/memory_keys.c index a7de4d8..5e023d2 100644 --- a/main/memory_keys.c +++ b/main/memory_keys.c @@ -18,6 +18,8 @@ bool memory_keys_is_sensitive(const char *key) NVS_KEY_TG_TOKEN, NVS_KEY_TG_CHAT_ID, NVS_KEY_TG_CHAT_IDS, + NVS_KEY_BRIDGE_URL, + NVS_KEY_BRIDGE_KEY, NVS_KEY_WIFI_PASS, NVS_KEY_LLM_BACKEND, NVS_KEY_LLM_MODEL, diff --git a/main/nvs_keys.h b/main/nvs_keys.h index cfe13d6..77c5df0 100644 --- a/main/nvs_keys.h +++ b/main/nvs_keys.h @@ -12,6 +12,8 @@ #define NVS_KEY_TG_TOKEN "tg_token" #define NVS_KEY_TG_CHAT_ID "tg_chat_id" #define NVS_KEY_TG_CHAT_IDS "tg_chat_ids" +#define NVS_KEY_BRIDGE_URL "bridge_url" +#define NVS_KEY_BRIDGE_KEY "bridge_key" #define NVS_KEY_TIMEZONE "timezone" #define NVS_KEY_PERSONA "persona" diff --git a/main/tools_email.c b/main/tools_email.c new file mode 100644 index 0000000..1927b1f --- /dev/null +++ b/main/tools_email.c @@ -0,0 +1,398 @@ +#include "tools_handlers.h" +#include "bridge_client.h" +#include "tools_common.h" +#include "cJSON.h" +#include "esp_err.h" +#include +#include + +#define EMAIL_TO_MAX_LEN 256 +#define EMAIL_SUBJECT_MAX_LEN 160 +#define EMAIL_BODY_MAX_LEN 2000 +#define EMAIL_LIST_LABEL_MAX_LEN 64 +#define EMAIL_MESSAGE_ID_MAX_LEN 256 +#define EMAIL_TOOL_RESPONSE_BUF_SIZE 2048 + +static const char *EMAIL_SEND_PATH = "/v1/email/send"; +static const char *EMAIL_LIST_PATH = "/v1/email/list"; +static const char *EMAIL_READ_PATH = "/v1/email/read"; + +static void first_line_or_fallback(const char *input, char *out, size_t out_len, const char *fallback) +{ + size_t i = 0; + + if (!out || out_len == 0) { + return; + } + + out[0] = '\0'; + if (!input || input[0] == '\0') { + snprintf(out, out_len, "%s", fallback ? fallback : ""); + return; + } + + while (input[i] != '\0' && input[i] != '\n' && input[i] != '\r' && i < out_len - 1) { + out[i] = input[i]; + i++; + } + out[i] = '\0'; + + if (out[0] == '\0' && fallback) { + snprintf(out, out_len, "%s", fallback); + } +} + +static bool get_required_string_field(const cJSON *input, + const char *name, + size_t max_len, + char *result, + size_t result_len, + const char **value_out) +{ + const cJSON *field; + char err[96]; + + if (!input || !name || !value_out) { + snprintf(result, result_len, "Error: internal input validation failed"); + return false; + } + + field = cJSON_GetObjectItemCaseSensitive((cJSON *)input, name); + if (!field || !cJSON_IsString(field) || !field->valuestring || field->valuestring[0] == '\0') { + snprintf(result, result_len, "Error: '%s' is required", name); + return false; + } + + if (!tools_validate_string_input(field->valuestring, max_len, err, sizeof(err))) { + snprintf(result, result_len, "Error: invalid '%s' (%s)", name, err + 7); + return false; + } + + *value_out = field->valuestring; + return true; +} + +static bool check_email_bridge_ready(char *result, size_t result_len) +{ + if (bridge_client_is_configured()) { + return true; + } + + snprintf(result, result_len, + "Error: email bridge is not configured. Provision bridge_url and bridge_key first."); + return false; +} + +static bool report_bridge_call_result(const char *operation, + esp_err_t err, + int status, + const char *response, + bool truncated, + char *result, + size_t result_len) +{ + char detail[192]; + + if (err == ESP_OK) { + return true; + } + + if (truncated) { + snprintf(result, result_len, + "Error: %s response exceeded buffer limits. Increase bridge response size or reduce payload.", + operation); + return false; + } + + first_line_or_fallback(response, detail, sizeof(detail), "no error details from bridge"); + snprintf(result, result_len, + "Error: %s failed (status=%d, err=%s): %s", + operation, + status, + esp_err_to_name(err), + detail); + return false; +} + +bool tools_email_send_handler(const cJSON *input, char *result, size_t result_len) +{ + const char *to = NULL; + const char *subject = NULL; + const char *body = NULL; + cJSON *req = NULL; + char response[EMAIL_TOOL_RESPONSE_BUF_SIZE]; + bool truncated = false; + int status = -1; + esp_err_t err; + cJSON *root = NULL; + cJSON *summary_json; + cJSON *message_json; + + if (!check_email_bridge_ready(result, result_len)) { + return false; + } + + if (!get_required_string_field(input, "to", EMAIL_TO_MAX_LEN, result, result_len, &to) || + !get_required_string_field(input, "subject", EMAIL_SUBJECT_MAX_LEN, result, result_len, &subject) || + !get_required_string_field(input, "body", EMAIL_BODY_MAX_LEN, result, result_len, &body)) { + return false; + } + + if (!strchr(to, '@')) { + snprintf(result, result_len, "Error: 'to' must be an email address"); + return false; + } + + req = cJSON_CreateObject(); + if (!req) { + snprintf(result, result_len, "Error: out of memory"); + return false; + } + cJSON_AddStringToObject(req, "to", to); + cJSON_AddStringToObject(req, "subject", subject); + cJSON_AddStringToObject(req, "body", body); + + err = bridge_client_post_json(EMAIL_SEND_PATH, req, response, sizeof(response), &status, &truncated); + cJSON_Delete(req); + + if (!report_bridge_call_result("email_send", err, status, response, truncated, result, result_len)) { + return false; + } + + root = cJSON_Parse(response); + if (!root) { + first_line_or_fallback(response, result, result_len, "Email send request accepted."); + return true; + } + + summary_json = cJSON_GetObjectItemCaseSensitive(root, "summary"); + message_json = cJSON_GetObjectItemCaseSensitive(root, "message"); + if (summary_json && cJSON_IsString(summary_json) && summary_json->valuestring) { + snprintf(result, result_len, "%s", summary_json->valuestring); + } else if (message_json && cJSON_IsString(message_json) && message_json->valuestring) { + snprintf(result, result_len, "%s", message_json->valuestring); + } else { + snprintf(result, result_len, "Email send request accepted."); + } + + cJSON_Delete(root); + return true; +} + +bool tools_email_list_handler(const cJSON *input, char *result, size_t result_len) +{ + const cJSON *label_json = NULL; + const cJSON *max_json = NULL; + const cJSON *unread_only_json = NULL; + cJSON *req = NULL; + cJSON *root = NULL; + cJSON *summary_json = NULL; + cJSON *items_json = NULL; + char response[EMAIL_TOOL_RESPONSE_BUF_SIZE]; + bool truncated = false; + int status = -1; + int max_items = 5; + bool unread_only = false; + esp_err_t err; + char *ptr = result; + size_t remaining = result_len; + + if (!check_email_bridge_ready(result, result_len)) { + return false; + } + + if (input) { + label_json = cJSON_GetObjectItemCaseSensitive((cJSON *)input, "label"); + if (label_json) { + char err_msg[96]; + if (!cJSON_IsString(label_json) || !label_json->valuestring || + !tools_validate_string_input(label_json->valuestring, EMAIL_LIST_LABEL_MAX_LEN, + err_msg, sizeof(err_msg))) { + snprintf(result, result_len, "Error: 'label' must be a short string"); + return false; + } + } + + max_json = cJSON_GetObjectItemCaseSensitive((cJSON *)input, "max"); + if (max_json) { + if (!cJSON_IsNumber(max_json)) { + snprintf(result, result_len, "Error: 'max' must be an integer between 1 and 20"); + return false; + } + max_items = max_json->valueint; + if (max_items < 1 || max_items > 20) { + snprintf(result, result_len, "Error: 'max' must be between 1 and 20"); + return false; + } + } + + unread_only_json = cJSON_GetObjectItemCaseSensitive((cJSON *)input, "unread_only"); + if (unread_only_json) { + if (!cJSON_IsBool(unread_only_json)) { + snprintf(result, result_len, "Error: 'unread_only' must be boolean"); + return false; + } + unread_only = cJSON_IsTrue(unread_only_json); + } + } + + req = cJSON_CreateObject(); + if (!req) { + snprintf(result, result_len, "Error: out of memory"); + return false; + } + cJSON_AddNumberToObject(req, "max", max_items); + cJSON_AddBoolToObject(req, "unread_only", unread_only); + if (label_json && cJSON_IsString(label_json) && label_json->valuestring && label_json->valuestring[0] != '\0') { + cJSON_AddStringToObject(req, "label", label_json->valuestring); + } + + err = bridge_client_post_json(EMAIL_LIST_PATH, req, response, sizeof(response), &status, &truncated); + cJSON_Delete(req); + + if (!report_bridge_call_result("email_list", err, status, response, truncated, result, result_len)) { + return false; + } + + root = cJSON_Parse(response); + if (!root) { + first_line_or_fallback(response, result, result_len, "Email list request completed."); + return true; + } + + summary_json = cJSON_GetObjectItemCaseSensitive(root, "summary"); + if (summary_json && cJSON_IsString(summary_json) && summary_json->valuestring) { + snprintf(result, result_len, "%s", summary_json->valuestring); + cJSON_Delete(root); + return true; + } + + items_json = cJSON_GetObjectItemCaseSensitive(root, "items"); + if (items_json && cJSON_IsArray(items_json)) { + int count = cJSON_GetArraySize(items_json); + if (count <= 0) { + snprintf(result, result_len, "No emails found."); + cJSON_Delete(root); + return true; + } + + tools_append_fmt(&ptr, &remaining, "Email list (%d):", count); + for (int i = 0; i < count && i < 5; i++) { + const cJSON *item = cJSON_GetArrayItem(items_json, i); + const cJSON *id_json = cJSON_GetObjectItemCaseSensitive((cJSON *)item, "id"); + const cJSON *from_json = cJSON_GetObjectItemCaseSensitive((cJSON *)item, "from"); + const cJSON *subject_json = cJSON_GetObjectItemCaseSensitive((cJSON *)item, "subject"); + const char *id = (id_json && cJSON_IsString(id_json) && id_json->valuestring) ? id_json->valuestring : "?"; + const char *from = (from_json && cJSON_IsString(from_json) && from_json->valuestring) ? from_json->valuestring : "?"; + const char *subject = (subject_json && cJSON_IsString(subject_json) && subject_json->valuestring) ? subject_json->valuestring : "(no subject)"; + tools_append_fmt(&ptr, &remaining, "\n%d) [%s] %s - %s", i + 1, id, from, subject); + } + cJSON_Delete(root); + return true; + } + + first_line_or_fallback(response, result, result_len, "Email list request completed."); + cJSON_Delete(root); + return true; +} + +bool tools_email_read_handler(const cJSON *input, char *result, size_t result_len) +{ + const char *id = NULL; + const cJSON *max_chars_json = NULL; + int max_chars = 1200; + cJSON *req = NULL; + cJSON *root = NULL; + cJSON *summary_json = NULL; + cJSON *subject_json = NULL; + cJSON *from_json = NULL; + cJSON *body_json = NULL; + const char *subject = "(no subject)"; + const char *from = "(unknown sender)"; + const char *body = ""; + char response[EMAIL_TOOL_RESPONSE_BUF_SIZE]; + bool truncated = false; + int status = -1; + esp_err_t err; + char body_preview[512]; + size_t body_len; + + if (!check_email_bridge_ready(result, result_len)) { + return false; + } + + if (!get_required_string_field(input, "id", EMAIL_MESSAGE_ID_MAX_LEN, result, result_len, &id)) { + return false; + } + + max_chars_json = input ? cJSON_GetObjectItemCaseSensitive((cJSON *)input, "max_chars") : NULL; + if (max_chars_json) { + if (!cJSON_IsNumber(max_chars_json)) { + snprintf(result, result_len, "Error: 'max_chars' must be an integer between 200 and 4000"); + return false; + } + max_chars = max_chars_json->valueint; + if (max_chars < 200 || max_chars > 4000) { + snprintf(result, result_len, "Error: 'max_chars' must be between 200 and 4000"); + return false; + } + } + + req = cJSON_CreateObject(); + if (!req) { + snprintf(result, result_len, "Error: out of memory"); + return false; + } + cJSON_AddStringToObject(req, "id", id); + cJSON_AddNumberToObject(req, "max_chars", max_chars); + + err = bridge_client_post_json(EMAIL_READ_PATH, req, response, sizeof(response), &status, &truncated); + cJSON_Delete(req); + + if (!report_bridge_call_result("email_read", err, status, response, truncated, result, result_len)) { + return false; + } + + root = cJSON_Parse(response); + if (!root) { + first_line_or_fallback(response, result, result_len, "Email read request completed."); + return true; + } + + summary_json = cJSON_GetObjectItemCaseSensitive(root, "summary"); + if (summary_json && cJSON_IsString(summary_json) && summary_json->valuestring) { + snprintf(result, result_len, "%s", summary_json->valuestring); + cJSON_Delete(root); + return true; + } + + subject_json = cJSON_GetObjectItemCaseSensitive(root, "subject"); + from_json = cJSON_GetObjectItemCaseSensitive(root, "from"); + body_json = cJSON_GetObjectItemCaseSensitive(root, "body_text"); + if (subject_json && cJSON_IsString(subject_json) && subject_json->valuestring) { + subject = subject_json->valuestring; + } + if (from_json && cJSON_IsString(from_json) && from_json->valuestring) { + from = from_json->valuestring; + } + if (body_json && cJSON_IsString(body_json) && body_json->valuestring) { + body = body_json->valuestring; + } + + body_len = strlen(body); + if (body_len >= sizeof(body_preview)) { + memcpy(body_preview, body, sizeof(body_preview) - 4); + body_preview[sizeof(body_preview) - 4] = '.'; + body_preview[sizeof(body_preview) - 3] = '.'; + body_preview[sizeof(body_preview) - 2] = '.'; + body_preview[sizeof(body_preview) - 1] = '\0'; + } else { + snprintf(body_preview, sizeof(body_preview), "%s", body); + } + + snprintf(result, result_len, + "Email %s\nFrom: %s\nSubject: %s\nBody: %s", + id, from, subject, body_preview); + cJSON_Delete(root); + return true; +} diff --git a/main/tools_handlers.h b/main/tools_handlers.h index 6101630..bd1829b 100644 --- a/main/tools_handlers.h +++ b/main/tools_handlers.h @@ -38,6 +38,9 @@ bool tools_get_timezone_handler(const cJSON *input, char *result, size_t result_ bool tools_get_version_handler(const cJSON *input, char *result, size_t result_len); bool tools_get_health_handler(const cJSON *input, char *result, size_t result_len); bool tools_get_diagnostics_handler(const cJSON *input, char *result, size_t result_len); +bool tools_email_send_handler(const cJSON *input, char *result, size_t result_len); +bool tools_email_list_handler(const cJSON *input, char *result, size_t result_len); +bool tools_email_read_handler(const cJSON *input, char *result, size_t result_len); bool tools_create_tool_handler(const cJSON *input, char *result, size_t result_len); bool tools_list_user_tools_handler(const cJSON *input, char *result, size_t result_len); bool tools_delete_user_tool_handler(const cJSON *input, char *result, size_t result_len); diff --git a/scripts/provision-dev.sh b/scripts/provision-dev.sh index f804d6b..334d89d 100755 --- a/scripts/provision-dev.sh +++ b/scripts/provision-dev.sh @@ -18,6 +18,8 @@ API_KEY_OVERRIDE="" API_URL_OVERRIDE="" TG_TOKEN_OVERRIDE="" TG_CHAT_IDS_OVERRIDE="" +BRIDGE_URL_OVERRIDE="" +BRIDGE_KEY_OVERRIDE="" SHOW_CONFIG=false WRITE_TEMPLATE=false SKIP_API_CHECK=false @@ -45,6 +47,8 @@ Overrides: --tg-token --tg-chat-id --tg-chat-ids Alias of --tg-chat-id + --bridge-url Bridge service base URL + --bridge-key Bridge service API key/token Examples: $0 --write-template @@ -68,6 +72,8 @@ ZCLAW_WIFI_PASS=YourWifiPassword ZCLAW_BACKEND=openai ZCLAW_MODEL=gpt-5.2 ZCLAW_API_URL= +ZCLAW_BRIDGE_URL= +ZCLAW_BRIDGE_KEY= # Prefer setting one API key here: ZCLAW_API_KEY= @@ -207,7 +213,7 @@ print_redacted_command() { continue fi case "$arg" in - --api-key|--tg-token|--pass) + --api-key|--tg-token|--pass|--bridge-key) printf " %q" "$arg" next_secret=true ;; @@ -277,6 +283,16 @@ while [ $# -gt 0 ]; do TG_CHAT_IDS_OVERRIDE="$2" shift 2 ;; + --bridge-url) + [ $# -ge 2 ] || { echo "Error: --bridge-url requires a value."; exit 1; } + BRIDGE_URL_OVERRIDE="$2" + shift 2 + ;; + --bridge-key) + [ $# -ge 2 ] || { echo "Error: --bridge-key requires a value."; exit 1; } + BRIDGE_KEY_OVERRIDE="$2" + shift 2 + ;; --show-config) SHOW_CONFIG=true shift @@ -332,6 +348,8 @@ MODEL="${MODEL_OVERRIDE:-${ZCLAW_MODEL:-}}" API_URL="${API_URL_OVERRIDE:-${ZCLAW_API_URL:-}}" TG_TOKEN="${TG_TOKEN_OVERRIDE:-${ZCLAW_TG_TOKEN:-}}" TG_CHAT_IDS="${TG_CHAT_IDS_OVERRIDE:-${ZCLAW_TG_CHAT_IDS:-${ZCLAW_TG_CHAT_ID:-}}}" +BRIDGE_URL="${BRIDGE_URL_OVERRIDE:-${ZCLAW_BRIDGE_URL:-}}" +BRIDGE_KEY="${BRIDGE_KEY_OVERRIDE:-${ZCLAW_BRIDGE_KEY:-}}" if [ "$PASS_OVERRIDE_SET" = true ]; then WIFI_PASS="$PASS_OVERRIDE" @@ -375,6 +393,8 @@ if [ "$SHOW_CONFIG" = true ]; then echo " Telegram bot ID: $BOT_ID (safe identifier)" echo " Telegram token: $(mask_secret "$TG_TOKEN")" echo " Telegram chat ID(s): $(mask_chat_id "$TG_CHAT_IDS")" + echo " Bridge URL: ${BRIDGE_URL:-}" + echo " Bridge key: $(mask_secret "$BRIDGE_KEY")" fi PROVISION_ARGS=(--yes) @@ -403,6 +423,12 @@ fi if [ -n "$TG_CHAT_IDS" ]; then PROVISION_ARGS+=(--tg-chat-id "$TG_CHAT_IDS") fi +if [ -n "$BRIDGE_URL" ]; then + PROVISION_ARGS+=(--bridge-url "$BRIDGE_URL") +fi +if [ -n "$BRIDGE_KEY" ]; then + PROVISION_ARGS+=(--bridge-key "$BRIDGE_KEY") +fi if [ "$SKIP_API_CHECK" = true ]; then PROVISION_ARGS+=(--skip-api-check) fi diff --git a/scripts/provision.sh b/scripts/provision.sh index 9a110c0..246546a 100755 --- a/scripts/provision.sh +++ b/scripts/provision.sh @@ -14,6 +14,8 @@ API_KEY="" API_URL="" TG_TOKEN="" TG_CHAT_IDS="" +BRIDGE_URL="" +BRIDGE_KEY="" ASSUME_YES=false VERIFY_API_KEY=true PRINT_DETECTED_SSID=false @@ -36,6 +38,8 @@ Options: --tg-token Telegram bot token (optional) --tg-chat-id Telegram chat ID allowlist (optional) --tg-chat-ids Alias of --tg-chat-id + --bridge-url Bridge service base URL (optional, required for bridge-backed tools) + --bridge-key Bridge service API key/token (optional) --yes Non-interactive (requires --api-key except ollama; SSID auto-detect if possible) --skip-api-check Skip live API key verification step --print-detected-ssid Print detected host WiFi SSID and exit (test/troubleshooting helper) @@ -850,6 +854,22 @@ while [ $# -gt 0 ]; do --tg-chat-ids=*) TG_CHAT_IDS="${1#*=}" ;; + --bridge-url) + shift + [ $# -gt 0 ] || { echo "Error: --bridge-url requires a value"; exit 1; } + BRIDGE_URL="$1" + ;; + --bridge-url=*) + BRIDGE_URL="${1#*=}" + ;; + --bridge-key) + shift + [ $# -gt 0 ] || { echo "Error: --bridge-key requires a value"; exit 1; } + BRIDGE_KEY="$1" + ;; + --bridge-key=*) + BRIDGE_KEY="${1#*=}" + ;; --yes) ASSUME_YES=true ;; @@ -1098,6 +1118,12 @@ fi if [ -n "$TG_TOKEN" ] && [ -z "$TG_CHAT_IDS" ]; then echo "Warning: Telegram token set without chat ID allowlist; incoming messages will be ignored." fi +if [ -n "$BRIDGE_URL" ] && [ -z "$BRIDGE_KEY" ]; then + echo "Warning: bridge URL set without key; bridge-backed tools will fail authentication." +fi +if [ -n "$BRIDGE_KEY" ] && [ -z "$BRIDGE_URL" ]; then + echo "Warning: bridge key set without URL; bridge-backed tools will remain disabled." +fi NVS_GEN="$IDF_PATH/components/nvs_flash/nvs_partition_generator/nvs_partition_gen.py" PARTTOOL="$IDF_PATH/components/partition_table/parttool.py" @@ -1136,6 +1162,12 @@ trap 'rm -rf "$tmpdir"' EXIT printf "tg_chat_id,data,string,%s\n" "$(csv_escape "$PRIMARY_TG_CHAT_ID")" printf "tg_chat_ids,data,string,%s\n" "$(csv_escape "$TG_CHAT_IDS")" fi + if [ -n "$BRIDGE_URL" ]; then + printf "bridge_url,data,string,%s\n" "$(csv_escape "$BRIDGE_URL")" + fi + if [ -n "$BRIDGE_KEY" ]; then + printf "bridge_key,data,string,%s\n" "$(csv_escape "$BRIDGE_KEY")" + fi } > "$csv_file" echo "Generating NVS credential image..." @@ -1159,6 +1191,12 @@ echo " Model: $MODEL" if [ -n "$API_URL" ]; then echo " API URL: $API_URL" fi +if [ -n "$BRIDGE_URL" ]; then + echo " Bridge URL: $BRIDGE_URL" +fi +if [ -n "$BRIDGE_KEY" ]; then + echo " Bridge key: " +fi echo "" echo "Next steps:" echo " 1) Board reset is automatic after provisioning" diff --git a/scripts/test.sh b/scripts/test.sh index 1585499..011289a 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -63,6 +63,7 @@ run_host_tests() { test_tools_gpio_policy.c \ test_builtin_tools_registry.c \ test_tools_system_diag.c \ + test_tools_email.c \ test_llm_auth.c \ test_wifi_credentials.c \ test_runner.c \ @@ -74,6 +75,8 @@ run_host_tests() { mock_tools.c \ mock_system_diag_deps.c \ mock_ratelimit.c \ + mock_bridge_client.c \ + mock_tools_common.c \ ../../main/json_util.c \ ../../main/cron_utils.c \ ../../main/security.c \ @@ -89,6 +92,7 @@ run_host_tests() { ../../main/agent.c \ ../../main/tools_gpio.c \ ../../main/tools_system.c \ + ../../main/tools_email.c \ $CJSON_LDFLAGS 2>&1 || { echo "Note: Failed to compile tests. Install cJSON:" echo " macOS: brew install cjson" diff --git a/test/host/mock_bridge_client.c b/test/host/mock_bridge_client.c new file mode 100644 index 0000000..822c0e4 --- /dev/null +++ b/test/host/mock_bridge_client.c @@ -0,0 +1,97 @@ +#include "mock_bridge_client.h" +#include "bridge_client.h" + +#include +#include +#include + +#define MOCK_BRIDGE_PATH_MAX 127 +#define MOCK_BRIDGE_PAYLOAD_MAX 4095 +#define MOCK_BRIDGE_RESPONSE_MAX 4095 + +static bool s_configured = true; +static esp_err_t s_response_err = ESP_OK; +static int s_response_status = 200; +static bool s_response_truncated = false; +static char s_response_body[MOCK_BRIDGE_RESPONSE_MAX + 1] = "{}"; +static char s_last_path[MOCK_BRIDGE_PATH_MAX + 1] = {0}; +static char s_last_payload[MOCK_BRIDGE_PAYLOAD_MAX + 1] = {0}; +static int s_post_calls = 0; + +void mock_bridge_client_reset(void) +{ + s_configured = true; + s_response_err = ESP_OK; + s_response_status = 200; + s_response_truncated = false; + snprintf(s_response_body, sizeof(s_response_body), "{}"); + s_last_path[0] = '\0'; + s_last_payload[0] = '\0'; + s_post_calls = 0; +} + +void mock_bridge_client_set_configured(bool configured) +{ + s_configured = configured; +} + +void mock_bridge_client_set_response(esp_err_t err, int status, bool truncated, const char *response) +{ + s_response_err = err; + s_response_status = status; + s_response_truncated = truncated; + snprintf(s_response_body, sizeof(s_response_body), "%s", response ? response : ""); +} + +const char *mock_bridge_client_last_path(void) +{ + return s_last_path; +} + +const char *mock_bridge_client_last_payload(void) +{ + return s_last_payload; +} + +int mock_bridge_client_post_calls(void) +{ + return s_post_calls; +} + +bool bridge_client_is_configured(void) +{ + return s_configured; +} + +esp_err_t bridge_client_post_json(const char *path, + const cJSON *payload, + char *response_out, + size_t response_out_len, + int *status_out, + bool *truncated_out) +{ + char *payload_json = NULL; + + if (!path || path[0] == '\0' || !response_out || response_out_len == 0) { + return ESP_ERR_INVALID_ARG; + } + + s_post_calls++; + snprintf(s_last_path, sizeof(s_last_path), "%s", path); + + if (payload) { + payload_json = cJSON_PrintUnformatted((cJSON *)payload); + } + snprintf(s_last_payload, sizeof(s_last_payload), "%s", payload_json ? payload_json : ""); + free(payload_json); + + if (status_out) { + *status_out = s_response_status; + } + if (truncated_out) { + *truncated_out = s_response_truncated; + } + + snprintf(response_out, response_out_len, "%s", s_response_body); + return s_response_err; +} diff --git a/test/host/mock_bridge_client.h b/test/host/mock_bridge_client.h new file mode 100644 index 0000000..35832b4 --- /dev/null +++ b/test/host/mock_bridge_client.h @@ -0,0 +1,14 @@ +#ifndef MOCK_BRIDGE_CLIENT_H +#define MOCK_BRIDGE_CLIENT_H + +#include +#include "esp_err.h" + +void mock_bridge_client_reset(void); +void mock_bridge_client_set_configured(bool configured); +void mock_bridge_client_set_response(esp_err_t err, int status, bool truncated, const char *response); +const char *mock_bridge_client_last_path(void); +const char *mock_bridge_client_last_payload(void); +int mock_bridge_client_post_calls(void); + +#endif // MOCK_BRIDGE_CLIENT_H diff --git a/test/host/mock_tools_common.c b/test/host/mock_tools_common.c new file mode 100644 index 0000000..c0792a7 --- /dev/null +++ b/test/host/mock_tools_common.c @@ -0,0 +1,94 @@ +#include "tools_common.h" + +#include +#include +#include + +bool tools_validate_string_input(const char *str, size_t max_len, char *error, size_t error_len) +{ + size_t i; + size_t len; + + if (!str) { + snprintf(error, error_len, "Error: null string"); + return false; + } + + len = strlen(str); + if (len > max_len) { + snprintf(error, error_len, "Error: string too long (max %zu chars)", max_len); + return false; + } + + for (i = 0; i < len; i++) { + char ch = str[i]; + if (ch < 0x20 && ch != '\n' && ch != '\t' && ch != '\r') { + snprintf(error, error_len, "Error: invalid character in input"); + return false; + } + } + + return true; +} + +bool tools_validate_nvs_key(const char *key, char *error, size_t error_len) +{ + (void)key; + (void)error; + (void)error_len; + return true; +} + +bool tools_validate_user_memory_key(const char *key, char *error, size_t error_len) +{ + (void)key; + (void)error; + (void)error_len; + return true; +} + +bool tools_append_fmt(char **ptr, size_t *remaining, const char *fmt, ...) +{ + int written = -1; + va_list args; + + if (!ptr || !*ptr || !remaining || *remaining == 0 || !fmt) { + return false; + } + + va_start(args, fmt); + if (strncmp(fmt, "Email list (", 12) == 0) { + int count = va_arg(args, int); + written = snprintf(*ptr, *remaining, "Email list (%d):", count); + } else if (fmt[0] == '\n') { + int idx = va_arg(args, int); + const char *id = va_arg(args, const char *); + const char *from = va_arg(args, const char *); + const char *subject = va_arg(args, const char *); + written = snprintf(*ptr, *remaining, "\n%d) [%s] %s - %s", idx, id, from, subject); + } + va_end(args); + + if (written < 0) { + return false; + } + + if ((size_t)written >= *remaining) { + *ptr += *remaining - 1; + *remaining = 1; + return false; + } + + *ptr += (size_t)written; + *remaining -= (size_t)written; + return true; +} + +bool tools_validate_https_url(const char *url, char *error, size_t error_len) +{ + if (!url || strncmp(url, "https://", 8) != 0) { + snprintf(error, error_len, "Error: URL must use HTTPS"); + return false; + } + return true; +} diff --git a/test/host/test_install_provision_scripts.py b/test/host/test_install_provision_scripts.py index 10fdb26..dde42b0 100644 --- a/test/host/test_install_provision_scripts.py +++ b/test/host/test_install_provision_scripts.py @@ -861,6 +861,74 @@ def test_provision_writes_chat_id_allowlist_and_legacy_primary_key(self) -> None self.assertIn('tg_chat_id,data,string,"7585013353"', captured_csv) self.assertIn('tg_chat_ids,data,string,"7585013353,-100222333444"', captured_csv) + def test_provision_writes_email_bridge_settings_when_provided(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + home = tmp / "home" + idf_dir = home / "esp" / "esp-idf" + nvs_gen = idf_dir / "components" / "nvs_flash" / "nvs_partition_generator" / "nvs_partition_gen.py" + parttool = idf_dir / "components" / "partition_table" / "parttool.py" + nvs_gen.parent.mkdir(parents=True, exist_ok=True) + parttool.parent.mkdir(parents=True, exist_ok=True) + nvs_gen.write_text("# nvs generator stub path\n", encoding="utf-8") + parttool.write_text("# parttool stub path\n", encoding="utf-8") + (idf_dir / "export.sh").write_text( + "export IDF_PATH=\"$HOME/esp/esp-idf\"\n", + encoding="utf-8", + ) + + bin_dir = tmp / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + _write_executable( + bin_dir / "python", + "#!/bin/sh\n" + "if [ \"$2\" = \"generate\" ]; then\n" + " cp \"$3\" \"$CSV_CAPTURE\"\n" + " : > \"$4\"\n" + " exit 0\n" + "fi\n" + "exit 0\n", + ) + + env = os.environ.copy() + env["HOME"] = str(home) + env["PATH"] = f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin" + env["TERM"] = "dumb" + env["CSV_CAPTURE"] = str(tmp / "captured-nvs.csv") + + proc = subprocess.run( + [ + str(PROVISION_SH), + "--yes", + "--skip-api-check", + "--port", + "/dev/null", + "--ssid", + "HomeNetwork", + "--pass", + "password123", + "--backend", + "openai", + "--api-key", + "sk-test", + "--bridge-url", + "https://bridge.example.com", + "--bridge-key", + "bridge-secret-token", + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + output = f"{proc.stdout}\n{proc.stderr}" + self.assertEqual(proc.returncode, 0, msg=output) + captured_csv = (tmp / "captured-nvs.csv").read_text(encoding="utf-8") + self.assertIn('bridge_url,data,string,"https://bridge.example.com"', captured_csv) + self.assertIn('bridge_key,data,string,"bridge-secret-token"', captured_csv) + def test_provision_ollama_writes_normalized_api_url_without_api_key(self) -> None: with tempfile.TemporaryDirectory() as td: tmp = Path(td) @@ -1129,6 +1197,8 @@ def test_provision_dev_write_template_creates_profile(self) -> None: self.assertIn("ZCLAW_WIFI_SSID", content) self.assertIn("ZCLAW_API_KEY", content) self.assertIn("ZCLAW_API_URL", content) + self.assertIn("ZCLAW_BRIDGE_URL", content) + self.assertIn("ZCLAW_BRIDGE_KEY", content) def test_provision_dev_forwards_profile_values(self) -> None: with tempfile.TemporaryDirectory() as td: @@ -1292,6 +1362,60 @@ def test_provision_dev_forwards_multi_telegram_chat_ids(self) -> None: self.assertIn("7585013353,-100222333444", args_text) self.assertIn("Telegram chat ID(s):", output) + def test_provision_dev_forwards_email_bridge_values(self) -> None: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + env_file = tmp / "dev.env" + args_file = tmp / "args.txt" + stub = tmp / "mock-provision.sh" + + _write_executable( + stub, + "#!/bin/sh\n" + "printf '%s\\n' \"$@\" > \"$ARGS_FILE\"\n", + ) + env_file.write_text( + "\n".join( + [ + "ZCLAW_WIFI_SSID=Trident", + "ZCLAW_BACKEND=openai", + "ZCLAW_API_KEY=sk-test-1234567890", + "ZCLAW_BRIDGE_URL=https://bridge.example.com", + "ZCLAW_BRIDGE_KEY=bridge-secret-token", + "", + ] + ), + encoding="utf-8", + ) + + env = os.environ.copy() + env["ARGS_FILE"] = str(args_file) + env["ZCLAW_PROVISION_SCRIPT"] = str(stub) + + proc = subprocess.run( + [ + str(PROVISION_DEV_SH), + "--env-file", + str(env_file), + "--show-config", + ], + cwd=PROJECT_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + output = f"{proc.stdout}\n{proc.stderr}" + self.assertEqual(proc.returncode, 0, msg=output) + args_text = args_file.read_text(encoding="utf-8") + self.assertIn("--bridge-url", args_text) + self.assertIn("https://bridge.example.com", args_text) + self.assertIn("--bridge-key", args_text) + self.assertIn("bridge-secret-token", args_text) + self.assertIn("Bridge URL: https://bridge.example.com", output) + self.assertNotIn("bridge-secret-token", output) + def test_provision_dev_errors_when_key_missing(self) -> None: with tempfile.TemporaryDirectory() as td: tmp = Path(td) diff --git a/test/host/test_memory_keys.c b/test/host/test_memory_keys.c index 1ec5bee..2d93f71 100644 --- a/test/host/test_memory_keys.c +++ b/test/host/test_memory_keys.c @@ -32,6 +32,8 @@ TEST(sensitive_exact_keys) ASSERT(memory_keys_is_sensitive(NVS_KEY_TG_TOKEN)); ASSERT(memory_keys_is_sensitive(NVS_KEY_TG_CHAT_ID)); ASSERT(memory_keys_is_sensitive(NVS_KEY_TG_CHAT_IDS)); + ASSERT(memory_keys_is_sensitive(NVS_KEY_BRIDGE_URL)); + ASSERT(memory_keys_is_sensitive(NVS_KEY_BRIDGE_KEY)); ASSERT(memory_keys_is_sensitive(NVS_KEY_WIFI_PASS)); ASSERT(memory_keys_is_sensitive(NVS_KEY_LLM_BACKEND)); ASSERT(memory_keys_is_sensitive(NVS_KEY_LLM_MODEL)); diff --git a/test/host/test_runner.c b/test/host/test_runner.c index d37a93f..311489a 100644 --- a/test/host/test_runner.c +++ b/test/host/test_runner.c @@ -20,6 +20,7 @@ extern int test_agent_all(void); extern int test_tools_gpio_policy_all(void); extern int test_builtin_tools_registry_all(void); extern int test_tools_system_diag_all(void); +extern int test_tools_email_all(void); extern int test_llm_auth_all(void); extern int test_wifi_credentials_all(void); @@ -45,6 +46,7 @@ int main(int argc, char *argv[]) failures += test_tools_gpio_policy_all(); failures += test_builtin_tools_registry_all(); failures += test_tools_system_diag_all(); + failures += test_tools_email_all(); failures += test_llm_auth_all(); failures += test_wifi_credentials_all(); diff --git a/test/host/test_tools_email.c b/test/host/test_tools_email.c new file mode 100644 index 0000000..91b0f7a --- /dev/null +++ b/test/host/test_tools_email.c @@ -0,0 +1,644 @@ +/* + * Host tests for email tool handlers. + */ + +#include +#include + +#include + +#include "mock_bridge_client.h" +#include "tools_handlers.h" + +#define TEST(name) static int test_##name(void) +#define ASSERT(cond) do { \ + if (!(cond)) { \ + printf(" FAIL: %s (line %d)\n", #cond, __LINE__); \ + return 1; \ + } \ +} while(0) +#define ASSERT_STR_EQ(a, b) do { \ + if (strcmp((a), (b)) != 0) { \ + printf(" FAIL: '%s' != '%s' (line %d)\n", (a), (b), __LINE__); \ + return 1; \ + } \ +} while(0) +#define ASSERT_STR_CONTAINS(haystack, needle) do { \ + if (strstr((haystack), (needle)) == NULL) { \ + printf(" FAIL: expected substring '%s' in '%s' (line %d)\n", (needle), (haystack), __LINE__); \ + return 1; \ + } \ +} while(0) + +static void test_setup(void) +{ + mock_bridge_client_reset(); + mock_bridge_client_set_configured(true); +} + +static cJSON *parse_last_payload(void) +{ + const char *payload = mock_bridge_client_last_payload(); + if (!payload || payload[0] == '\0') { + return NULL; + } + return cJSON_Parse(payload); +} + +static void fill_chars(char *buf, size_t len, char ch) +{ + size_t i; + for (i = 0; i < len; i++) { + buf[i] = ch; + } + buf[len] = '\0'; +} + +TEST(send_rejects_unconfigured_bridge) +{ + cJSON *input = cJSON_Parse("{\"to\":\"a@example.com\",\"subject\":\"s\",\"body\":\"b\"}"); + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_configured(false); + ASSERT(input != NULL); + ASSERT(!tools_email_send_handler(input, result, sizeof(result))); + ASSERT_STR_CONTAINS(result, "email bridge is not configured"); + ASSERT(mock_bridge_client_post_calls() == 0); + + cJSON_Delete(input); + return 0; +} + +TEST(send_requires_to) +{ + cJSON *input = cJSON_Parse("{\"subject\":\"s\",\"body\":\"b\"}"); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + ASSERT(!tools_email_send_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "Error: 'to' is required"); + + cJSON_Delete(input); + return 0; +} + +TEST(send_rejects_invalid_email_address) +{ + cJSON *input = cJSON_Parse("{\"to\":\"invalid\",\"subject\":\"s\",\"body\":\"b\"}"); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + ASSERT(!tools_email_send_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "Error: 'to' must be an email address"); + + cJSON_Delete(input); + return 0; +} + +TEST(send_forwards_payload_and_uses_summary) +{ + cJSON *input = cJSON_Parse("{\"to\":\"a@example.com\",\"subject\":\"hello\",\"body\":\"body\"}"); + cJSON *payload = NULL; + cJSON *to_json; + cJSON *subject_json; + cJSON *body_json; + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_response(ESP_OK, 202, false, "{\"summary\":\"queued\"}"); + ASSERT(input != NULL); + ASSERT(tools_email_send_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "queued"); + ASSERT_STR_EQ(mock_bridge_client_last_path(), "/v1/email/send"); + ASSERT(mock_bridge_client_post_calls() == 1); + + payload = parse_last_payload(); + ASSERT(payload != NULL); + to_json = cJSON_GetObjectItemCaseSensitive(payload, "to"); + subject_json = cJSON_GetObjectItemCaseSensitive(payload, "subject"); + body_json = cJSON_GetObjectItemCaseSensitive(payload, "body"); + ASSERT(cJSON_IsString(to_json) && strcmp(to_json->valuestring, "a@example.com") == 0); + ASSERT(cJSON_IsString(subject_json) && strcmp(subject_json->valuestring, "hello") == 0); + ASSERT(cJSON_IsString(body_json) && strcmp(body_json->valuestring, "body") == 0); + + cJSON_Delete(payload); + cJSON_Delete(input); + return 0; +} + +TEST(send_uses_message_when_summary_missing) +{ + cJSON *input = cJSON_Parse("{\"to\":\"a@example.com\",\"subject\":\"hello\",\"body\":\"body\"}"); + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_response(ESP_OK, 200, false, "{\"message\":\"accepted\"}"); + ASSERT(input != NULL); + ASSERT(tools_email_send_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "accepted"); + + cJSON_Delete(input); + return 0; +} + +TEST(send_uses_default_when_json_has_no_summary_or_message) +{ + cJSON *input = cJSON_Parse("{\"to\":\"a@example.com\",\"subject\":\"hello\",\"body\":\"body\"}"); + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_response(ESP_OK, 200, false, "{\"ok\":true}"); + ASSERT(input != NULL); + ASSERT(tools_email_send_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "Email send request accepted."); + + cJSON_Delete(input); + return 0; +} + +TEST(send_uses_first_line_for_non_json_response) +{ + cJSON *input = cJSON_Parse("{\"to\":\"a@example.com\",\"subject\":\"hello\",\"body\":\"body\"}"); + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_response(ESP_OK, 200, false, "queued on provider\ntrace-id:123"); + ASSERT(input != NULL); + ASSERT(tools_email_send_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "queued on provider"); + + cJSON_Delete(input); + return 0; +} + +TEST(send_reports_bridge_error_with_status_and_detail) +{ + cJSON *input = cJSON_Parse("{\"to\":\"a@example.com\",\"subject\":\"hello\",\"body\":\"body\"}"); + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_response(ESP_FAIL, 500, false, "upstream timeout\ntrace"); + ASSERT(input != NULL); + ASSERT(!tools_email_send_handler(input, result, sizeof(result))); + ASSERT_STR_CONTAINS(result, "email_send failed"); + ASSERT_STR_CONTAINS(result, "status=500"); + ASSERT_STR_CONTAINS(result, "ESP_FAIL"); + ASSERT_STR_CONTAINS(result, "upstream timeout"); + + cJSON_Delete(input); + return 0; +} + +TEST(send_reports_truncated_bridge_response) +{ + cJSON *input = cJSON_Parse("{\"to\":\"a@example.com\",\"subject\":\"hello\",\"body\":\"body\"}"); + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_response(ESP_FAIL, 502, true, "ignored"); + ASSERT(input != NULL); + ASSERT(!tools_email_send_handler(input, result, sizeof(result))); + ASSERT_STR_CONTAINS(result, "response exceeded buffer limits"); + + cJSON_Delete(input); + return 0; +} + +TEST(list_defaults_when_input_is_null) +{ + cJSON *payload = NULL; + cJSON *max_json; + cJSON *unread_only_json; + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_response(ESP_OK, 200, false, "{\"summary\":\"3 inbox emails\"}"); + ASSERT(tools_email_list_handler(NULL, result, sizeof(result))); + ASSERT_STR_EQ(result, "3 inbox emails"); + ASSERT_STR_EQ(mock_bridge_client_last_path(), "/v1/email/list"); + ASSERT(mock_bridge_client_post_calls() == 1); + + payload = parse_last_payload(); + ASSERT(payload != NULL); + max_json = cJSON_GetObjectItemCaseSensitive(payload, "max"); + unread_only_json = cJSON_GetObjectItemCaseSensitive(payload, "unread_only"); + ASSERT(cJSON_IsNumber(max_json) && max_json->valueint == 5); + ASSERT(cJSON_IsBool(unread_only_json) && !cJSON_IsTrue(unread_only_json)); + ASSERT(cJSON_GetObjectItemCaseSensitive(payload, "label") == NULL); + + cJSON_Delete(payload); + return 0; +} + +TEST(list_rejects_label_not_string) +{ + cJSON *input = cJSON_Parse("{\"label\":123}"); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + ASSERT(!tools_email_list_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "Error: 'label' must be a short string"); + + cJSON_Delete(input); + return 0; +} + +TEST(list_rejects_label_too_long) +{ + char long_label[80]; + cJSON *input = cJSON_CreateObject(); + char result[512] = {0}; + + fill_chars(long_label, 70, 'x'); + test_setup(); + ASSERT(input != NULL); + cJSON_AddStringToObject(input, "label", long_label); + ASSERT(!tools_email_list_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "Error: 'label' must be a short string"); + + cJSON_Delete(input); + return 0; +} + +TEST(list_rejects_max_not_number) +{ + cJSON *input = cJSON_Parse("{\"max\":\"5\"}"); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + ASSERT(!tools_email_list_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "Error: 'max' must be an integer between 1 and 20"); + + cJSON_Delete(input); + return 0; +} + +TEST(list_rejects_max_out_of_range) +{ + cJSON *input = cJSON_Parse("{\"max\":21}"); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + ASSERT(!tools_email_list_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "Error: 'max' must be between 1 and 20"); + + cJSON_Delete(input); + return 0; +} + +TEST(list_rejects_unread_only_not_bool) +{ + cJSON *input = cJSON_Parse("{\"unread_only\":\"yes\"}"); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + ASSERT(!tools_email_list_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "Error: 'unread_only' must be boolean"); + + cJSON_Delete(input); + return 0; +} + +TEST(list_forwards_optional_fields) +{ + cJSON *input = cJSON_Parse("{\"label\":\"INBOX\",\"max\":7,\"unread_only\":true}"); + cJSON *payload = NULL; + cJSON *label_json; + cJSON *max_json; + cJSON *unread_only_json; + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_response(ESP_OK, 200, false, "{\"summary\":\"ok\"}"); + ASSERT(input != NULL); + ASSERT(tools_email_list_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "ok"); + ASSERT_STR_EQ(mock_bridge_client_last_path(), "/v1/email/list"); + + payload = parse_last_payload(); + ASSERT(payload != NULL); + label_json = cJSON_GetObjectItemCaseSensitive(payload, "label"); + max_json = cJSON_GetObjectItemCaseSensitive(payload, "max"); + unread_only_json = cJSON_GetObjectItemCaseSensitive(payload, "unread_only"); + ASSERT(cJSON_IsString(label_json) && strcmp(label_json->valuestring, "INBOX") == 0); + ASSERT(cJSON_IsNumber(max_json) && max_json->valueint == 7); + ASSERT(cJSON_IsBool(unread_only_json) && cJSON_IsTrue(unread_only_json)); + + cJSON_Delete(payload); + cJSON_Delete(input); + return 0; +} + +TEST(list_renders_items_and_limits_to_five_lines) +{ + cJSON *input = cJSON_CreateObject(); + char result[2048] = {0}; + + test_setup(); + ASSERT(input != NULL); + mock_bridge_client_set_response( + ESP_OK, + 200, + false, + "{\"items\":[" + "{\"id\":\"id1\",\"from\":\"f1@example.com\",\"subject\":\"s1\"}," + "{\"id\":\"id2\",\"from\":\"f2@example.com\",\"subject\":\"s2\"}," + "{\"id\":\"id3\",\"from\":\"f3@example.com\",\"subject\":\"s3\"}," + "{\"id\":\"id4\",\"from\":\"f4@example.com\",\"subject\":\"s4\"}," + "{\"id\":\"id5\",\"from\":\"f5@example.com\",\"subject\":\"s5\"}," + "{\"id\":\"id6\",\"from\":\"f6@example.com\",\"subject\":\"s6\"}" + "]}"); + + ASSERT(tools_email_list_handler(input, result, sizeof(result))); + ASSERT_STR_CONTAINS(result, "Email list (6):"); + ASSERT_STR_CONTAINS(result, "1) [id1] f1@example.com"); + ASSERT_STR_CONTAINS(result, "5) [id5] f5@example.com"); + ASSERT(strstr(result, "id6") == NULL); + + cJSON_Delete(input); + return 0; +} + +TEST(list_reports_no_emails_for_empty_items) +{ + cJSON *input = cJSON_CreateObject(); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + mock_bridge_client_set_response(ESP_OK, 200, false, "{\"items\":[]}"); + ASSERT(tools_email_list_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "No emails found."); + + cJSON_Delete(input); + return 0; +} + +TEST(list_uses_first_line_for_non_json_response) +{ + cJSON *input = cJSON_CreateObject(); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + mock_bridge_client_set_response(ESP_OK, 200, false, "list complete\ntrace"); + ASSERT(tools_email_list_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "list complete"); + + cJSON_Delete(input); + return 0; +} + +TEST(list_reports_bridge_error) +{ + cJSON *input = cJSON_CreateObject(); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + mock_bridge_client_set_response(ESP_FAIL, 503, false, "bridge down"); + ASSERT(!tools_email_list_handler(input, result, sizeof(result))); + ASSERT_STR_CONTAINS(result, "email_list failed"); + ASSERT_STR_CONTAINS(result, "status=503"); + ASSERT_STR_CONTAINS(result, "bridge down"); + + cJSON_Delete(input); + return 0; +} + +TEST(list_reports_truncated_bridge_response) +{ + cJSON *input = cJSON_CreateObject(); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + mock_bridge_client_set_response(ESP_FAIL, 500, true, "ignored"); + ASSERT(!tools_email_list_handler(input, result, sizeof(result))); + ASSERT_STR_CONTAINS(result, "response exceeded buffer limits"); + + cJSON_Delete(input); + return 0; +} + +TEST(read_requires_id) +{ + cJSON *input = cJSON_Parse("{\"max_chars\":300}"); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + ASSERT(!tools_email_read_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "Error: 'id' is required"); + + cJSON_Delete(input); + return 0; +} + +TEST(read_rejects_max_chars_not_number) +{ + cJSON *input = cJSON_Parse("{\"id\":\"abc\",\"max_chars\":\"300\"}"); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + ASSERT(!tools_email_read_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "Error: 'max_chars' must be an integer between 200 and 4000"); + + cJSON_Delete(input); + return 0; +} + +TEST(read_rejects_max_chars_out_of_range) +{ + cJSON *input = cJSON_Parse("{\"id\":\"abc\",\"max_chars\":100}"); + char result[512] = {0}; + + test_setup(); + ASSERT(input != NULL); + ASSERT(!tools_email_read_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "Error: 'max_chars' must be between 200 and 4000"); + + cJSON_Delete(input); + return 0; +} + +TEST(read_forwards_payload_and_formats_email_view) +{ + cJSON *input = cJSON_Parse("{\"id\":\"msg-1\"}"); + cJSON *payload = NULL; + cJSON *id_json; + cJSON *max_chars_json; + char result[1024] = {0}; + + test_setup(); + mock_bridge_client_set_response( + ESP_OK, + 200, + false, + "{\"from\":\"sender@example.com\",\"subject\":\"Test\",\"body_text\":\"Hello world\"}"); + ASSERT(input != NULL); + ASSERT(tools_email_read_handler(input, result, sizeof(result))); + ASSERT_STR_CONTAINS(result, "Email msg-1"); + ASSERT_STR_CONTAINS(result, "From: sender@example.com"); + ASSERT_STR_CONTAINS(result, "Subject: Test"); + ASSERT_STR_CONTAINS(result, "Body: Hello world"); + ASSERT_STR_EQ(mock_bridge_client_last_path(), "/v1/email/read"); + + payload = parse_last_payload(); + ASSERT(payload != NULL); + id_json = cJSON_GetObjectItemCaseSensitive(payload, "id"); + max_chars_json = cJSON_GetObjectItemCaseSensitive(payload, "max_chars"); + ASSERT(cJSON_IsString(id_json) && strcmp(id_json->valuestring, "msg-1") == 0); + ASSERT(cJSON_IsNumber(max_chars_json) && max_chars_json->valueint == 1200); + + cJSON_Delete(payload); + cJSON_Delete(input); + return 0; +} + +TEST(read_uses_summary_when_present) +{ + cJSON *input = cJSON_Parse("{\"id\":\"msg-1\"}"); + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_response(ESP_OK, 200, false, "{\"summary\":\"message unavailable\"}"); + ASSERT(input != NULL); + ASSERT(tools_email_read_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "message unavailable"); + + cJSON_Delete(input); + return 0; +} + +TEST(read_truncates_long_body_preview) +{ + char long_body[700]; + char response[1024]; + cJSON *input = cJSON_Parse("{\"id\":\"msg-2\"}"); + char result[2048] = {0}; + + fill_chars(long_body, 640, 'a'); + snprintf(response, sizeof(response), + "{\"from\":\"sender@example.com\",\"subject\":\"Long\",\"body_text\":\"%s\"}", + long_body); + + test_setup(); + mock_bridge_client_set_response(ESP_OK, 200, false, response); + ASSERT(input != NULL); + ASSERT(tools_email_read_handler(input, result, sizeof(result))); + ASSERT_STR_CONTAINS(result, "Email msg-2"); + ASSERT_STR_CONTAINS(result, "Body: "); + ASSERT_STR_CONTAINS(result, "..."); + + cJSON_Delete(input); + return 0; +} + +TEST(read_uses_first_line_for_non_json_response) +{ + cJSON *input = cJSON_Parse("{\"id\":\"msg-3\"}"); + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_response(ESP_OK, 200, false, "read complete\ntrace"); + ASSERT(input != NULL); + ASSERT(tools_email_read_handler(input, result, sizeof(result))); + ASSERT_STR_EQ(result, "read complete"); + + cJSON_Delete(input); + return 0; +} + +TEST(read_reports_bridge_error) +{ + cJSON *input = cJSON_Parse("{\"id\":\"msg-4\"}"); + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_response(ESP_FAIL, 404, false, "not found"); + ASSERT(input != NULL); + ASSERT(!tools_email_read_handler(input, result, sizeof(result))); + ASSERT_STR_CONTAINS(result, "email_read failed"); + ASSERT_STR_CONTAINS(result, "status=404"); + ASSERT_STR_CONTAINS(result, "not found"); + + cJSON_Delete(input); + return 0; +} + +TEST(read_reports_truncated_bridge_response) +{ + cJSON *input = cJSON_Parse("{\"id\":\"msg-5\"}"); + char result[512] = {0}; + + test_setup(); + mock_bridge_client_set_response(ESP_FAIL, 502, true, "ignored"); + ASSERT(input != NULL); + ASSERT(!tools_email_read_handler(input, result, sizeof(result))); + ASSERT_STR_CONTAINS(result, "response exceeded buffer limits"); + + cJSON_Delete(input); + return 0; +} + +int test_tools_email_all(void) +{ + int failures = 0; + + printf("\nEmail Tool Tests:\n"); + +#define RUN_TEST(name) do { \ + printf(" " #name "... "); \ + if (test_##name() == 0) { \ + printf("OK\n"); \ + } else { \ + failures++; \ + } \ +} while(0) + + RUN_TEST(send_rejects_unconfigured_bridge); + RUN_TEST(send_requires_to); + RUN_TEST(send_rejects_invalid_email_address); + RUN_TEST(send_forwards_payload_and_uses_summary); + RUN_TEST(send_uses_message_when_summary_missing); + RUN_TEST(send_uses_default_when_json_has_no_summary_or_message); + RUN_TEST(send_uses_first_line_for_non_json_response); + RUN_TEST(send_reports_bridge_error_with_status_and_detail); + RUN_TEST(send_reports_truncated_bridge_response); + + RUN_TEST(list_defaults_when_input_is_null); + RUN_TEST(list_rejects_label_not_string); + RUN_TEST(list_rejects_label_too_long); + RUN_TEST(list_rejects_max_not_number); + RUN_TEST(list_rejects_max_out_of_range); + RUN_TEST(list_rejects_unread_only_not_bool); + RUN_TEST(list_forwards_optional_fields); + RUN_TEST(list_renders_items_and_limits_to_five_lines); + RUN_TEST(list_reports_no_emails_for_empty_items); + RUN_TEST(list_uses_first_line_for_non_json_response); + RUN_TEST(list_reports_bridge_error); + RUN_TEST(list_reports_truncated_bridge_response); + + RUN_TEST(read_requires_id); + RUN_TEST(read_rejects_max_chars_not_number); + RUN_TEST(read_rejects_max_chars_out_of_range); + RUN_TEST(read_forwards_payload_and_formats_email_view); + RUN_TEST(read_uses_summary_when_present); + RUN_TEST(read_truncates_long_body_preview); + RUN_TEST(read_uses_first_line_for_non_json_response); + RUN_TEST(read_reports_bridge_error); + RUN_TEST(read_reports_truncated_bridge_response); + +#undef RUN_TEST + + return failures; +}