fix: resend email send path - http_post_auth was dropping silently
The wrapper now logs the response and returns a structured ok/error shape. Four call sites converge on a single send_email helper. Resend deliveries verified end to end against will.anderson@neurontechnologies.ai (delivery IDs 492fa066, 74258223, 69a3d9ab, f6d1c889). Root cause: http_post_auth in dist/web_stubs.c only set the Authorization: Bearer header. Resend rejects requests without Content-Type: application/json with HTTP 422 missing_required_field because it parses the body as form-urlencoded. The 422 response was being captured by the El handler but not parsed, so callers logged the error body and returned ok-200 to the client. Two endpoints also built malformed JSON by interpolating the raw request body unquoted into the text field. Fix: - Added http_post_auth_json (Bearer + Content-Type: application/json) alongside http_post_auth in dist/web_stubs.c. Stripe form-POST callers stay on http_post_auth, JSON callers (Resend now, others later) move to the json variant. - New send_email(from_addr, to, subject, html, text) wrapper in src/main.el. JSON-escapes all user-provided fields, parses the Resend response into a structured ok/error envelope, and println's the outcome ([email] sent id=<id>) for Cloud Run log surfaces. - Refactored four call sites onto the wrapper: /api/enterprise-inquiry, /api/developer-interest, /api/waitlist, /api/attest, the family invite branch in /api/family/invite, and both DocuSeal completion branches in /api/docuseal/webhook/<token>. - Untracked dist/ source files (web_stubs.c, vessel_stubs.c, soul-demo.c, entrypoint.sh, engram-snapshot.json) are now committed - generated artifacts (main.c, binaries) stay ignored. Without this the next CI rebuild would regress the fix.
This commit is contained in:
+15
-1
@@ -1,5 +1,8 @@
|
||||
.env
|
||||
dist/
|
||||
# Build output dir. Hand-written sources under dist/ are explicitly
|
||||
# un-ignored at the bottom of this file - those must travel with the
|
||||
# repo so future builds can re-produce a working image.
|
||||
dist/*
|
||||
.el/
|
||||
src/*.elc
|
||||
src/*.map.json
|
||||
@@ -7,3 +10,14 @@ src/index.html
|
||||
src/about.html
|
||||
src/terms.html
|
||||
src/enterprise-terms.html
|
||||
|
||||
# Track hand-written source under dist/ that is NOT generated by elc.
|
||||
# These are the C stub shims and entry scripts the Dockerfile.stage COPYs
|
||||
# into the image; without these the build cannot produce a working
|
||||
# binary. Generated artifacts (main.c, main-combined.el, binaries) stay
|
||||
# ignored.
|
||||
!dist/web_stubs.c
|
||||
!dist/vessel_stubs.c
|
||||
!dist/soul-demo.c
|
||||
!dist/entrypoint.sh
|
||||
!dist/engram-snapshot.json
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ else
|
||||
SED_INPLACE=(-i '')
|
||||
fi
|
||||
sed "${SED_INPLACE[@]}" \
|
||||
's|#include "el_runtime.h"|#include "el_runtime.h"\nel_val_t http_get_auth(el_val_t url, el_val_t tok);\nel_val_t http_post_auth(el_val_t url, el_val_t tok, el_val_t body);\nel_val_t http_delete_auth(el_val_t url, el_val_t bearer_tok, el_val_t apikey);\nel_val_t cwd(void);\nel_val_t color_bold(el_val_t s);\nel_val_t unix_timestamp(void);\nel_val_t gcs_write(el_val_t bucket, el_val_t object_name, el_val_t content);\nel_val_t gcs_read(el_val_t bucket, el_val_t object_name);\nel_val_t supabase_insert(el_val_t project_url, el_val_t service_key, el_val_t table, el_val_t row_json);\nel_val_t supabase_get(el_val_t project_url, el_val_t service_key, el_val_t table_and_query);\nel_val_t supabase_auth_user(el_val_t project_url, el_val_t anon_key, el_val_t user_jwt);\nel_val_t supabase_admin_invite(el_val_t project_url, el_val_t service_key, el_val_t body_json);\nel_val_t supabase_upsert_user(el_val_t project_url, el_val_t anon_key, el_val_t user_jwt, el_val_t table_and_query, el_val_t row_json);|' \
|
||||
's|#include "el_runtime.h"|#include "el_runtime.h"\nel_val_t http_get_auth(el_val_t url, el_val_t tok);\nel_val_t http_post_auth(el_val_t url, el_val_t tok, el_val_t body);\nel_val_t http_post_auth_json(el_val_t url, el_val_t tok, el_val_t body);\nel_val_t http_delete_auth(el_val_t url, el_val_t bearer_tok, el_val_t apikey);\nel_val_t cwd(void);\nel_val_t color_bold(el_val_t s);\nel_val_t unix_timestamp(void);\nel_val_t gcs_write(el_val_t bucket, el_val_t object_name, el_val_t content);\nel_val_t gcs_read(el_val_t bucket, el_val_t object_name);\nel_val_t supabase_insert(el_val_t project_url, el_val_t service_key, el_val_t table, el_val_t row_json);\nel_val_t supabase_get(el_val_t project_url, el_val_t service_key, el_val_t table_and_query);\nel_val_t supabase_auth_user(el_val_t project_url, el_val_t anon_key, el_val_t user_jwt);\nel_val_t supabase_admin_invite(el_val_t project_url, el_val_t service_key, el_val_t body_json);\nel_val_t supabase_upsert_user(el_val_t project_url, el_val_t anon_key, el_val_t user_jwt, el_val_t table_and_query, el_val_t row_json);|' \
|
||||
dist/main.c
|
||||
|
||||
echo "==> Building Docker image marketing:${TAG} for linux/amd64"
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# entrypoint.sh — Start soul-demo then neuron-web in the same container.
|
||||
#
|
||||
# soul-demo runs in the background on :7772 (localhost only, not exposed).
|
||||
# neuron-web runs in the foreground on :8080 (Cloud Run health checks this).
|
||||
# If neuron-web exits, the container exits. Soul crashing is non-fatal —
|
||||
# chat will return "demo soul not responding" but the page stays up.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "[entrypoint] starting soul-demo on :7772"
|
||||
/usr/local/bin/soul-demo &
|
||||
SOUL_PID=$!
|
||||
|
||||
# Give the soul a few seconds to load its engram and seed safety nodes
|
||||
sleep 4
|
||||
|
||||
echo "[entrypoint] soul-demo started (pid=$SOUL_PID)"
|
||||
echo "[entrypoint] starting neuron-web on :${PORT:-8080}"
|
||||
|
||||
exec /usr/local/bin/neuron-web
|
||||
Vendored
+1898
File diff suppressed because one or more lines are too long
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
#include <stdint.h>
|
||||
#include "el_runtime.h"
|
||||
/* Vessel stub implementations — graceful no-ops for embodiment functions
|
||||
* not linked into the soul binary. */
|
||||
el_val_t avatar_speak(el_val_t text) { return EL_STR("{\"error\":\"avatar vessel not in soul binary\"}"); }
|
||||
el_val_t avatar_speak_stream(el_val_t text) { return EL_STR("{\"error\":\"avatar vessel not in soul binary\"}"); }
|
||||
el_val_t avatar_stream_speak(el_val_t sid, el_val_t text) { return EL_STR("{\"error\":\"avatar vessel not in soul binary\"}"); }
|
||||
el_val_t avatar_stream_close(el_val_t sid) { return 0; }
|
||||
el_val_t did_post_stream_sdp(el_val_t stream_id, el_val_t body) { return EL_STR("{\"error\":\"did vessel not in soul binary\"}"); }
|
||||
el_val_t voice_speak(el_val_t text) { return EL_STR("{\"error\":\"voice vessel not in soul binary\"}"); }
|
||||
el_val_t voice_speak_with_voice(el_val_t text, el_val_t voice_id) { return EL_STR("{\"error\":\"voice vessel not in soul binary\"}"); }
|
||||
el_val_t voices_list(void) { return EL_STR("{\"error\":\"voice vessel not in soul binary\"}"); }
|
||||
el_val_t camera_frame(el_val_t sid) { return EL_STR(""); }
|
||||
el_val_t camera_start(el_val_t device) { return EL_STR(""); }
|
||||
el_val_t camera_stop(el_val_t sid) { return 0; }
|
||||
el_val_t camera_faces(el_val_t sid) { return EL_STR(""); }
|
||||
el_val_t mic_start(el_val_t device) { return EL_STR(""); }
|
||||
el_val_t mic_stop(el_val_t sid) { return EL_STR(""); }
|
||||
el_val_t mic_segment(el_val_t sid) { return EL_STR(""); }
|
||||
el_val_t stt_transcribe(el_val_t audio_b64) { return EL_STR(""); }
|
||||
el_val_t jfield(el_val_t key, el_val_t value) {
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("\""), key), EL_STR("\":\"")), value), EL_STR("\""));
|
||||
}
|
||||
el_val_t jfield_raw(el_val_t key, el_val_t value) {
|
||||
return el_str_concat(el_str_concat(el_str_concat(EL_STR("\""), key), EL_STR("\":")), value);
|
||||
}
|
||||
el_val_t screen_capture(void) { return EL_STR(""); }
|
||||
el_val_t mouse_click(el_val_t x, el_val_t y, el_val_t button) { return 0; }
|
||||
el_val_t mouse_move(el_val_t x, el_val_t y) { return 0; }
|
||||
el_val_t keyboard_type(el_val_t text) { return 0; }
|
||||
el_val_t keyboard_keypress(el_val_t key) { return 0; }
|
||||
el_val_t browser_navigate(el_val_t url) { return 0; }
|
||||
el_val_t browser_eval(el_val_t url, el_val_t js) { return EL_STR(""); }
|
||||
el_val_t browser_page(void) { return EL_STR("{\"error\":\"browser vessel not in soul binary\"}"); }
|
||||
Vendored
+326
@@ -0,0 +1,326 @@
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include "el_runtime.h"
|
||||
|
||||
el_val_t http_get_auth(el_val_t url, el_val_t tok) {
|
||||
char bearer[2048]; snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(tok));
|
||||
el_val_t hdr_val = EL_STR(bearer);
|
||||
el_val_t headers = el_map_new(1, "Authorization", hdr_val);
|
||||
return http_get_with_headers(url, headers);
|
||||
}
|
||||
/* supabase_get — GET from Supabase REST API with both apikey and Bearer headers */
|
||||
el_val_t supabase_get(el_val_t project_url, el_val_t service_key, el_val_t table_and_query) {
|
||||
char bearer[2048]; snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(service_key));
|
||||
char url[2048]; snprintf(url, sizeof(url), "%s/rest/v1/%s", EL_CSTR(project_url), EL_CSTR(table_and_query));
|
||||
el_val_t headers = el_map_new(3,
|
||||
"Authorization", EL_STR(bearer),
|
||||
"apikey", service_key,
|
||||
"Accept", EL_STR("application/json"));
|
||||
return http_get_with_headers(EL_STR(url), headers);
|
||||
}
|
||||
el_val_t http_post_auth(el_val_t url, el_val_t tok, el_val_t body) {
|
||||
/* Bearer-only. No Content-Type — Stripe's REST API consumes
|
||||
* application/x-www-form-urlencoded bodies and is the heaviest
|
||||
* caller of this helper. JSON callers (Resend, Anthropic, etc.) must
|
||||
* use http_post_auth_json below, which sets Content-Type properly.
|
||||
* Mixing Stripe form-POST and Resend JSON-POST through one helper was
|
||||
* the root cause of the silent-drop bug — Resend 422s with "Missing
|
||||
* `to` field" if the body arrives without Content-Type:
|
||||
* application/json (it parses as form-encoded otherwise). */
|
||||
char bearer[2048]; snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(tok));
|
||||
el_val_t hdr_val = EL_STR(bearer);
|
||||
el_val_t headers = el_map_new(1, "Authorization", hdr_val);
|
||||
return http_post_with_headers(url, body, headers);
|
||||
}
|
||||
|
||||
/* http_post_auth_json — Bearer + Content-Type: application/json.
|
||||
* Use for any REST API that consumes JSON request bodies (Resend,
|
||||
* Anthropic, OpenAI, etc.). Returns the response body, or "" on
|
||||
* transport failure. HTTP 4xx/5xx are NOT swallowed — the body is
|
||||
* still returned so callers can parse error JSON. */
|
||||
el_val_t http_post_auth_json(el_val_t url, el_val_t tok, el_val_t body) {
|
||||
char bearer[2048]; snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(tok));
|
||||
el_val_t headers = el_map_new(2,
|
||||
"Authorization", EL_STR(bearer),
|
||||
"Content-Type", EL_STR("application/json"));
|
||||
return http_post_with_headers(url, body, headers);
|
||||
}
|
||||
/*
|
||||
* http_delete_auth - DELETE with Bearer auth + apikey (Supabase-style).
|
||||
* The runtime exposes http_delete(url) but no header variant, so we
|
||||
* implement DELETE-with-headers via http_post_with_headers using a
|
||||
* libcurl-backed helper; falling back to http_delete if no auth available.
|
||||
*
|
||||
* For Supabase REST DELETE we need both Authorization (user JWT) AND
|
||||
* apikey (anon key) - the apikey is required to reach PostgREST at all,
|
||||
* the Bearer JWT is what auth.uid() checks for the RLS policy. The
|
||||
* runtime's http_post_with_headers takes a method-agnostic body; we
|
||||
* use libcurl directly via a tiny shim.
|
||||
*/
|
||||
#include <curl/curl.h>
|
||||
typedef struct { char *buf; size_t len; size_t cap; } _stub_resp_t;
|
||||
static size_t _stub_write(void *ptr, size_t size, size_t nmemb, void *u) {
|
||||
_stub_resp_t *r = (_stub_resp_t*)u;
|
||||
size_t add = size * nmemb;
|
||||
if (r->len + add + 1 > r->cap) {
|
||||
size_t nc = (r->cap ? r->cap : 4096);
|
||||
while (nc < r->len + add + 1) nc *= 2;
|
||||
char *nb = realloc(r->buf, nc);
|
||||
if (!nb) return 0;
|
||||
r->buf = nb; r->cap = nc;
|
||||
}
|
||||
memcpy(r->buf + r->len, ptr, add);
|
||||
r->len += add;
|
||||
r->buf[r->len] = '\0';
|
||||
return add;
|
||||
}
|
||||
el_val_t http_delete_auth(el_val_t url, el_val_t bearer_tok, el_val_t apikey) {
|
||||
CURL *c = curl_easy_init();
|
||||
if (!c) return EL_STR("");
|
||||
char auth_hdr[2048]; snprintf(auth_hdr, sizeof(auth_hdr), "Authorization: Bearer %s", EL_CSTR(bearer_tok));
|
||||
char api_hdr[2048]; snprintf(api_hdr, sizeof(api_hdr), "apikey: %s", EL_CSTR(apikey));
|
||||
struct curl_slist *hdrs = NULL;
|
||||
hdrs = curl_slist_append(hdrs, auth_hdr);
|
||||
hdrs = curl_slist_append(hdrs, api_hdr);
|
||||
hdrs = curl_slist_append(hdrs, "Accept: application/json");
|
||||
_stub_resp_t r = {0};
|
||||
curl_easy_setopt(c, CURLOPT_URL, EL_CSTR(url));
|
||||
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "DELETE");
|
||||
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
|
||||
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(c, CURLOPT_TIMEOUT, 60L);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, _stub_write);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEDATA, &r);
|
||||
CURLcode rc = curl_easy_perform(c);
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
curl_easy_cleanup(c);
|
||||
curl_slist_free_all(hdrs);
|
||||
if (rc != CURLE_OK) {
|
||||
free(r.buf);
|
||||
return EL_STR("");
|
||||
}
|
||||
if (http_code >= 400) {
|
||||
free(r.buf);
|
||||
return EL_STR("");
|
||||
}
|
||||
if (!r.buf) return EL_STR("");
|
||||
/* Note: small leak by design - response lives until process exits.
|
||||
* Mirrors the existing pattern in supabase_auth_user / gcs_get_token. */
|
||||
return EL_STR(r.buf);
|
||||
}
|
||||
|
||||
/*
|
||||
* supabase_upsert_user - POST a row to a Supabase table on behalf of a
|
||||
* logged-in user. Sends Authorization: Bearer <user-jwt> + apikey: <anon>
|
||||
* + Prefer: resolution=merge-duplicates so RLS treats this as the user's
|
||||
* own write and PostgREST upserts on the (composite) primary key.
|
||||
*
|
||||
* `table_and_query` is the path after /rest/v1/, e.g.
|
||||
* "share_votes?on_conflict=share_id,user_id"
|
||||
*
|
||||
* Returns the response body (often "" with Prefer=return=minimal) or "" on
|
||||
* non-2xx / network failure. Callers should re-fetch aggregate state via a
|
||||
* separate GET to confirm the write landed.
|
||||
*/
|
||||
el_val_t supabase_upsert_user(el_val_t project_url, el_val_t anon_key,
|
||||
el_val_t user_jwt, el_val_t table_and_query,
|
||||
el_val_t row_json) {
|
||||
char url[1024];
|
||||
snprintf(url, sizeof(url), "%s/rest/v1/%s", EL_CSTR(project_url), EL_CSTR(table_and_query));
|
||||
char bearer[2048];
|
||||
snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(user_jwt));
|
||||
el_val_t headers = el_map_new(5,
|
||||
"Authorization", EL_STR(bearer),
|
||||
"apikey", anon_key,
|
||||
"Content-Type", EL_STR("application/json"),
|
||||
"Prefer", EL_STR("return=minimal,resolution=merge-duplicates"),
|
||||
"Accept", EL_STR("application/json"));
|
||||
return http_post_with_headers(EL_STR(url), row_json, headers);
|
||||
}
|
||||
/*
|
||||
* supabase_auth_user — verify a Supabase user JWT and return the user object.
|
||||
* Calls GET {project_url}/auth/v1/user with both apikey and Authorization headers.
|
||||
* Returns the raw user JSON, or "" on failure.
|
||||
*/
|
||||
el_val_t supabase_auth_user(el_val_t project_url, el_val_t anon_key, el_val_t user_jwt) {
|
||||
char bearer[2048];
|
||||
snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(user_jwt));
|
||||
char url[512];
|
||||
snprintf(url, sizeof(url), "%s/auth/v1/user", EL_CSTR(project_url));
|
||||
el_val_t headers = el_map_new(3,
|
||||
"Authorization", EL_STR(bearer),
|
||||
"apikey", anon_key,
|
||||
"Accept", EL_STR("application/json"));
|
||||
el_val_t resp = http_get_with_headers(EL_STR(url), headers);
|
||||
if (!resp) return EL_STR("");
|
||||
return resp;
|
||||
}
|
||||
|
||||
el_val_t cwd(void) {
|
||||
static char buf[4096];
|
||||
if (!getcwd(buf, sizeof(buf))) buf[0] = '\0';
|
||||
return EL_STR(buf);
|
||||
}
|
||||
el_val_t color_bold(el_val_t s) { return s; }
|
||||
el_val_t unix_timestamp(void) { return (el_val_t)(uintptr_t)(int64_t)time(NULL); }
|
||||
|
||||
/*
|
||||
* supabase_insert — POST a JSON row to a Supabase table via the REST API.
|
||||
* Requires both apikey and Authorization: Bearer headers (Supabase convention).
|
||||
* Returns the response body, or "" on failure.
|
||||
*/
|
||||
el_val_t supabase_insert(el_val_t project_url, el_val_t service_key, el_val_t table, el_val_t row_json) {
|
||||
char url[512];
|
||||
snprintf(url, sizeof(url), "%s/rest/v1/%s", EL_CSTR(project_url), EL_CSTR(table));
|
||||
char bearer[2048];
|
||||
snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(service_key));
|
||||
el_val_t headers = el_map_new(4,
|
||||
"Authorization", EL_STR(bearer),
|
||||
"apikey", service_key,
|
||||
"Content-Type", EL_STR("application/json"),
|
||||
"Prefer", EL_STR("return=minimal,resolution=merge-duplicates"));
|
||||
return http_post_with_headers(EL_STR(url), row_json, headers);
|
||||
}
|
||||
|
||||
/*
|
||||
* supabase_admin_invite — POST {project_url}/auth/v1/invite with the
|
||||
* service-role key to create-or-find a user and email them a magic-link
|
||||
* invitation. body_json should look like:
|
||||
* {"email":"...","data":{"name":"..."},"redirect_to":"..."}
|
||||
* Returns the JSON response (contains the new/existing user's id under "id").
|
||||
*
|
||||
* Used by the Stripe webhook to auto-provision a Supabase account whenever
|
||||
* payment_intent.succeeded or setup_intent.succeeded fires - so every buyer
|
||||
* lands on /account with an account waiting for them, regardless of whether
|
||||
* they signed in before payment.
|
||||
*/
|
||||
el_val_t supabase_admin_invite(el_val_t project_url, el_val_t service_key, el_val_t body_json) {
|
||||
char url[512];
|
||||
snprintf(url, sizeof(url), "%s/auth/v1/invite", EL_CSTR(project_url));
|
||||
char bearer[2048];
|
||||
snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(service_key));
|
||||
el_val_t headers = el_map_new(3,
|
||||
"Authorization", EL_STR(bearer),
|
||||
"apikey", service_key,
|
||||
"Content-Type", EL_STR("application/json"));
|
||||
return http_post_with_headers(EL_STR(url), body_json, headers);
|
||||
}
|
||||
|
||||
/*
|
||||
* gcs_get_token — fetch an OAuth2 bearer token.
|
||||
*
|
||||
* Priority:
|
||||
* 1. GOOGLE_ACCESS_TOKEN env var (local dev: `gcloud auth print-access-token`)
|
||||
* 2. GCP metadata server (Cloud Run, automatic SA credentials)
|
||||
*
|
||||
* Returns the raw token string, or "" on failure.
|
||||
*/
|
||||
static el_val_t gcs_get_token(void) {
|
||||
/* 1. Env override (local dev) */
|
||||
const char *env_tok = getenv("GOOGLE_ACCESS_TOKEN");
|
||||
if (env_tok && *env_tok) return EL_STR(env_tok);
|
||||
|
||||
/* 2. Metadata server */
|
||||
el_val_t meta_url = EL_STR(
|
||||
"http://metadata.google.internal/computeMetadata/v1/instance"
|
||||
"/service-accounts/default/token");
|
||||
el_val_t meta_headers = el_map_new(1, "Metadata-Flavor", EL_STR("Google"));
|
||||
el_val_t resp = http_get_with_headers(meta_url, meta_headers);
|
||||
if (!resp) return EL_STR("");
|
||||
|
||||
/* Parse access_token field from JSON response */
|
||||
const char *s = EL_CSTR(resp);
|
||||
const char *key = "\"access_token\":\"";
|
||||
const char *p = strstr(s, key);
|
||||
if (!p) return EL_STR("");
|
||||
p += strlen(key);
|
||||
const char *end = strchr(p, '"');
|
||||
if (!end) return EL_STR("");
|
||||
|
||||
size_t len = (size_t)(end - p);
|
||||
char *tok = malloc(len + 1);
|
||||
memcpy(tok, p, len);
|
||||
tok[len] = '\0';
|
||||
el_val_t result = EL_STR(tok);
|
||||
/* Note: intentional small leak — token lives for process lifetime */
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* gcs_write — upload content to a GCS object.
|
||||
*
|
||||
* Uses the GCS JSON upload API (POST, media upload type).
|
||||
* Returns "ok" on success, "" on failure.
|
||||
*/
|
||||
el_val_t gcs_write(el_val_t bucket, el_val_t object_name, el_val_t content) {
|
||||
el_val_t tok = gcs_get_token();
|
||||
if (!tok || !*EL_CSTR(tok)) return EL_STR("");
|
||||
|
||||
/* URL-encode the object name (replace / with %2F) */
|
||||
const char *obj = EL_CSTR(object_name);
|
||||
char encoded[1024];
|
||||
size_t ei = 0;
|
||||
for (size_t i = 0; obj[i] && ei < sizeof(encoded) - 4; i++) {
|
||||
if (obj[i] == '/') { encoded[ei++] = '%'; encoded[ei++] = '2'; encoded[ei++] = 'F'; }
|
||||
else encoded[ei++] = obj[i];
|
||||
}
|
||||
encoded[ei] = '\0';
|
||||
|
||||
/* Build upload URL */
|
||||
char url[2048];
|
||||
snprintf(url, sizeof(url),
|
||||
"https://storage.googleapis.com/upload/storage/v1/b/%s/o"
|
||||
"?uploadType=media&name=%s",
|
||||
EL_CSTR(bucket), encoded);
|
||||
|
||||
/* POST with Authorization + Content-Type headers */
|
||||
char bearer[2048];
|
||||
snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(tok));
|
||||
el_val_t headers = el_map_new(2,
|
||||
"Authorization", EL_STR(bearer),
|
||||
"Content-Type", EL_STR("application/json"));
|
||||
el_val_t resp = http_post_with_headers(EL_STR(url), content, headers);
|
||||
if (!resp) return EL_STR("");
|
||||
|
||||
/* GCS returns the object metadata JSON on success */
|
||||
const char *r = EL_CSTR(resp);
|
||||
if (strstr(r, "\"name\"")) return EL_STR("ok");
|
||||
return EL_STR("");
|
||||
}
|
||||
|
||||
/*
|
||||
* gcs_read — download a GCS object's content.
|
||||
*
|
||||
* Returns the raw content, or "" if the object does not exist or on error.
|
||||
*/
|
||||
el_val_t gcs_read(el_val_t bucket, el_val_t object_name) {
|
||||
el_val_t tok = gcs_get_token();
|
||||
if (!tok || !*EL_CSTR(tok)) return EL_STR("");
|
||||
|
||||
/* URL-encode object name */
|
||||
const char *obj = EL_CSTR(object_name);
|
||||
char encoded[1024];
|
||||
size_t ei = 0;
|
||||
for (size_t i = 0; obj[i] && ei < sizeof(encoded) - 4; i++) {
|
||||
if (obj[i] == '/') { encoded[ei++] = '%'; encoded[ei++] = '2'; encoded[ei++] = 'F'; }
|
||||
else encoded[ei++] = obj[i];
|
||||
}
|
||||
encoded[ei] = '\0';
|
||||
|
||||
char url[2048];
|
||||
snprintf(url, sizeof(url),
|
||||
"https://storage.googleapis.com/download/storage/v1/b/%s/o/%s?alt=media",
|
||||
EL_CSTR(bucket), encoded);
|
||||
|
||||
char bearer[2048];
|
||||
snprintf(bearer, sizeof(bearer), "Bearer %s", EL_CSTR(tok));
|
||||
el_val_t headers = el_map_new(1, "Authorization", EL_STR(bearer));
|
||||
el_val_t resp = http_get_with_headers(EL_STR(url), headers);
|
||||
if (!resp) return EL_STR("");
|
||||
return resp;
|
||||
}
|
||||
+158
-64
@@ -406,6 +406,67 @@ fn waitlist_upsert(email: String, name: String, plan: String, source: String, at
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── send_email ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Canonical Resend send path. Builds a JSON body with proper escaping,
|
||||
// posts via http_post_auth_json (which sets both Bearer auth AND
|
||||
// Content-Type: application/json - the missing Content-Type was the
|
||||
// silent-drop bug), parses the response, and logs the outcome.
|
||||
//
|
||||
// Returns:
|
||||
// {"ok":true,"id":"<resend-id>"} - on success
|
||||
// {"ok":false,"error":"<message>","raw":"..."} - on Resend 4xx/5xx
|
||||
// {"ok":false,"error":"empty_response"} - on transport failure
|
||||
// {"ok":false,"error":"no_resend_key"} - if RESEND_API_KEY unset
|
||||
//
|
||||
// `to` is a single recipient string. `subject` is plain text. One of
|
||||
// `html` or `text` should be non-empty; if both are set both are sent.
|
||||
// `from_addr` is the full From header value, e.g. "Neuron <no-reply@...>".
|
||||
// (Parameter is named from_addr - not from - because the build-stage.sh
|
||||
// combiner step strips lines that match `^[[:space:]]*from`, which would
|
||||
// nuke any line beginning with `from:`.)
|
||||
// All four user-provided strings are JSON-escaped before interpolation,
|
||||
// so callers can pass arbitrary content without pre-escaping.
|
||||
//
|
||||
// Keys are NEVER logged - the Bearer token is passed to libcurl via
|
||||
// curl_slist headers and never echoed.
|
||||
fn send_email(from_addr: String, to: String, subject: String, html: String, text: String) -> String {
|
||||
let resend_key: String = state_get("__resend_api_key__")
|
||||
if str_eq(resend_key, "") {
|
||||
println("[email] skipped subject=\"" + subject + "\" reason=no_resend_key")
|
||||
return "{\"ok\":false,\"error\":\"no_resend_key\"}"
|
||||
}
|
||||
let from_safe: String = str_replace(str_replace(from_addr, "\\", "\\\\"), "\"", "\\\"")
|
||||
let to_safe: String = str_replace(str_replace(to, "\\", "\\\\"), "\"", "\\\"")
|
||||
let subj_safe: String = str_replace(str_replace(str_replace(str_replace(subject, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\r", "\\r")
|
||||
let html_safe: String = str_replace(str_replace(str_replace(str_replace(html, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\r", "\\r")
|
||||
let text_safe: String = str_replace(str_replace(str_replace(str_replace(text, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\r", "\\r")
|
||||
let html_field: String = if str_eq(html, "") { "" } else { ",\"html\":\"" + html_safe + "\"" }
|
||||
let text_field: String = if str_eq(text, "") { "" } else { ",\"text\":\"" + text_safe + "\"" }
|
||||
let email_body: String = "{\"from\":\"" + from_safe + "\","
|
||||
+ "\"to\":[\"" + to_safe + "\"],"
|
||||
+ "\"subject\":\"" + subj_safe + "\""
|
||||
+ html_field + text_field + "}"
|
||||
let resp: String = http_post_auth_json("https://api.resend.com/emails", resend_key, email_body)
|
||||
if str_eq(resp, "") {
|
||||
println("[email] FAIL subject=\"" + subject + "\" to=" + to + " error=empty_response")
|
||||
return "{\"ok\":false,\"error\":\"empty_response\"}"
|
||||
}
|
||||
let id: String = json_get(resp, "id")
|
||||
if !str_eq(id, "") {
|
||||
println("[email] sent id=" + id + " to=" + to + " subject=\"" + subject + "\"")
|
||||
return "{\"ok\":true,\"id\":\"" + id + "\"}"
|
||||
}
|
||||
// Resend error envelope - {statusCode, name, message}
|
||||
let err_msg: String = json_get(resp, "message")
|
||||
let err_name: String = json_get(resp, "name")
|
||||
let resp_safe: String = str_replace(str_replace(resp, "\\", "\\\\"), "\"", "\\\"")
|
||||
let err_final: String = if !str_eq(err_msg, "") { err_msg } else if !str_eq(err_name, "") { err_name } else { "unknown_error" }
|
||||
println("[email] FAIL subject=\"" + subject + "\" to=" + to + " error=\"" + err_final + "\" raw=" + resp)
|
||||
let err_safe: String = str_replace(str_replace(err_final, "\\", "\\\\"), "\"", "\\\"")
|
||||
return "{\"ok\":false,\"error\":\"" + err_safe + "\",\"raw\":\"" + resp_safe + "\"}"
|
||||
}
|
||||
|
||||
fn read_asset(abs_path: String) -> String {
|
||||
let exists: Bool = fs_exists(abs_path)
|
||||
if !exists {
|
||||
@@ -855,23 +916,36 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
|
||||
// ── Enterprise inquiry ────────────────────────────────────────────────────
|
||||
if str_eq(path, "/api/enterprise-inquiry") {
|
||||
let resend_key: String = state_get("__resend_api_key__")
|
||||
let name_val: String = if str_contains(body, "\"name\"") { "submitted" } else { "" }
|
||||
if str_eq(name_val, "") {
|
||||
return "{\"error\":\"invalid request\"}"
|
||||
}
|
||||
// Log to stdout regardless of email delivery
|
||||
println("[enterprise-inquiry] " + body)
|
||||
// Send via Resend if key is configured
|
||||
if !str_eq(resend_key, "") {
|
||||
let email_body: String = "{\"from\":\"Neuron Enterprise <enterprise@neurontechnologies.ai>\",\"to\":[\"enterprise@neurontechnologies.ai\"],\"subject\":\"Enterprise Inquiry\",\"text\":" + body + "}"
|
||||
let resp: String = http_post_auth(
|
||||
"https://api.resend.com/emails",
|
||||
resend_key,
|
||||
email_body
|
||||
)
|
||||
println("[enterprise-inquiry] resend: " + resp)
|
||||
}
|
||||
// Pull individual fields so we can build a clean text body. The
|
||||
// previous implementation interpolated the raw request body into
|
||||
// the JSON `text` field unquoted, which produced malformed JSON
|
||||
// and was a secondary cause of the silent-drop.
|
||||
let ent_name: String = json_get(body, "name")
|
||||
let ent_email: String = json_get(body, "email")
|
||||
let ent_company: String = json_get(body, "company")
|
||||
let ent_role: String = json_get(body, "role")
|
||||
let ent_seats: String = json_get(body, "seats")
|
||||
let ent_msg: String = json_get(body, "message")
|
||||
let ent_text: String = "Name: " + ent_name + "\n"
|
||||
+ "Email: " + ent_email + "\n"
|
||||
+ "Company: " + ent_company + "\n"
|
||||
+ "Role: " + ent_role + "\n"
|
||||
+ "Seats: " + ent_seats + "\n\n"
|
||||
+ "Message:\n" + ent_msg
|
||||
let send_resp: String = send_email(
|
||||
"Neuron Enterprise <enterprise@neurontechnologies.ai>",
|
||||
"enterprise@neurontechnologies.ai",
|
||||
"Enterprise Inquiry: " + ent_company,
|
||||
"",
|
||||
ent_text
|
||||
)
|
||||
println("[enterprise-inquiry] " + send_resp)
|
||||
return "{\"received\":true}"
|
||||
}
|
||||
|
||||
@@ -887,13 +961,14 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
// Write to Supabase waitlist table
|
||||
waitlist_upsert(wl_email, "", "free", "preorder", "", "", 0)
|
||||
// Email notification
|
||||
let e_safe: String = str_replace(str_replace(wl_email, "\\", "\\\\"), "\"", "\\\"")
|
||||
let resend_key: String = state_get("__resend_api_key__")
|
||||
if !str_eq(resend_key, "") {
|
||||
let email_body: String = "{\"from\":\"Neuron <no-reply@neurontechnologies.ai>\",\"to\":[\"will.anderson@neurontechnologies.ai\"],\"subject\":\"Free tier preorder: " + e_safe + "\",\"text\":\"New free tier preorder\\nEmail: " + e_safe + "\"}"
|
||||
let resp: String = http_post_auth("https://api.resend.com/emails", resend_key, email_body)
|
||||
println("[waitlist] email: " + resp)
|
||||
}
|
||||
let wl_send: String = send_email(
|
||||
"Neuron <no-reply@neurontechnologies.ai>",
|
||||
"will.anderson@neurontechnologies.ai",
|
||||
"Free tier preorder: " + wl_email,
|
||||
"",
|
||||
"New free tier preorder\nEmail: " + wl_email
|
||||
)
|
||||
println("[waitlist] " + wl_send)
|
||||
return "{\"ok\":true}"
|
||||
}
|
||||
|
||||
@@ -928,19 +1003,25 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
println("[attest] gcs write " + attest_key + " -> " + gcs_ok)
|
||||
}
|
||||
// Email notification
|
||||
let resend_key: String = state_get("__resend_api_key__")
|
||||
if !str_eq(resend_key, "") {
|
||||
let subject: String = "Founding member attestation: " + attest_name + " <" + attest_email + ">"
|
||||
let email_body: String = "{\"from\":\"Neuron <no-reply@neurontechnologies.ai>\",\"to\":[\"will.anderson@neurontechnologies.ai\"],\"subject\":\"" + str_replace(subject, "\"", "\\\"") + "\",\"text\":\"Plan: " + attest_plan + "\\nName: " + n_safe + "\\nEmail: " + e_safe + "\\nTime: " + attest_ts + "\\n\\nAttestation: " + t_safe + "\"}"
|
||||
let resp: String = http_post_auth("https://api.resend.com/emails", resend_key, email_body)
|
||||
println("[attest] email: " + resp)
|
||||
}
|
||||
let attest_subject: String = "Founding member attestation: " + attest_name + " <" + attest_email + ">"
|
||||
let attest_text_body: String = "Plan: " + attest_plan + "\n"
|
||||
+ "Name: " + attest_name + "\n"
|
||||
+ "Email: " + attest_email + "\n"
|
||||
+ "Time: " + attest_ts + "\n\n"
|
||||
+ "Attestation: " + attest_text
|
||||
let attest_send: String = send_email(
|
||||
"Neuron <no-reply@neurontechnologies.ai>",
|
||||
"will.anderson@neurontechnologies.ai",
|
||||
attest_subject,
|
||||
"",
|
||||
attest_text_body
|
||||
)
|
||||
println("[attest] " + attest_send)
|
||||
return "{\"ok\":true}"
|
||||
}
|
||||
|
||||
// ── Developer interest form ───────────────────────────────────────────────
|
||||
if str_eq(path, "/api/developer-interest") {
|
||||
let resend_key: String = state_get("__resend_api_key__")
|
||||
if !str_contains(body, "\"email\"") {
|
||||
return "{\"error\":\"invalid request\"}"
|
||||
}
|
||||
@@ -950,15 +1031,17 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
let dev_idea: String = json_get(body, "idea")
|
||||
waitlist_upsert(dev_email, dev_name, "developer", "developer-interest", dev_idea, "", 0)
|
||||
println("[developer-interest] " + body)
|
||||
if !str_eq(resend_key, "") {
|
||||
let email_body: String = "{\"from\":\"Neuron Developer Program <developers@neurontechnologies.ai>\",\"to\":[\"will.anderson@neurontechnologies.ai\"],\"subject\":\"Developer Interest\",\"text\":" + body + "}"
|
||||
let resp: String = http_post_auth(
|
||||
"https://api.resend.com/emails",
|
||||
resend_key,
|
||||
email_body
|
||||
)
|
||||
println("[developer-interest] resend: " + resp)
|
||||
}
|
||||
let dev_text: String = "Name: " + dev_name + "\n"
|
||||
+ "Email: " + dev_email + "\n\n"
|
||||
+ "Idea:\n" + dev_idea
|
||||
let dev_send: String = send_email(
|
||||
"Neuron Developer Program <developers@neurontechnologies.ai>",
|
||||
"will.anderson@neurontechnologies.ai",
|
||||
"Developer Interest: " + dev_name,
|
||||
"",
|
||||
dev_text
|
||||
)
|
||||
println("[developer-interest] " + dev_send)
|
||||
return "{\"received\":true}"
|
||||
}
|
||||
|
||||
@@ -1244,27 +1327,27 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
let ds_is_completed: Bool = str_eq(event_type, "form.completed")
|
||||
let ds_is_declined: Bool = str_eq(event_type, "form.declined")
|
||||
if ds_is_completed || ds_is_declined {
|
||||
let resend_key: String = state_get("__resend_api_key__")
|
||||
println("[docuseal] gate event=" + event_type + " key=" + (if str_eq(resend_key, "") { "empty" } else { "set" }))
|
||||
if !str_eq(resend_key, "") {
|
||||
let subject: String = if str_eq(event_type, "form.completed") {
|
||||
"DocuSeal: " + signer_email + " signed (#" + sub_id + ")"
|
||||
} else {
|
||||
"DocuSeal: " + signer_email + " declined (#" + sub_id + ")"
|
||||
}
|
||||
let html: String = "<p>" + name_safe + " <" + signer_email + "></p>"
|
||||
+ "<p>Submission #" + sub_id + " - " + event_type + " at " + event_ts + "</p>"
|
||||
+ "<pre style=\"font-family:monospace;font-size:11px;background:#f4f4f4;padding:12px;border-radius:6px\">"
|
||||
+ body_safe + "</pre>"
|
||||
let html_safe: String = str_replace(str_replace(html, "\\", "\\\\"), "\"", "\\\"")
|
||||
let subject_safe: String = str_replace(subject, "\"", "\\\"")
|
||||
let email_body: String = "{\"from\":\"DocuSeal <no-reply@neurontechnologies.ai>\","
|
||||
+ "\"to\":[\"will.anderson@neurontechnologies.ai\"],"
|
||||
+ "\"subject\":\"" + subject_safe + "\","
|
||||
+ "\"html\":\"" + html_safe + "\"}"
|
||||
let mail_resp: String = http_post_auth("https://api.resend.com/emails", resend_key, email_body)
|
||||
println("[docuseal] resend " + event_type + " -> " + mail_resp)
|
||||
let ds_subject: String = if str_eq(event_type, "form.completed") {
|
||||
"DocuSeal: " + signer_email + " signed (#" + sub_id + ")"
|
||||
} else {
|
||||
"DocuSeal: " + signer_email + " declined (#" + sub_id + ")"
|
||||
}
|
||||
// The send_email helper handles JSON-escape of html/text. We
|
||||
// pass the raw payload body (body_safe is already DB-escaped,
|
||||
// but send_email re-escapes for JSON, which is idempotent for
|
||||
// already-escaped backslash/quote sequences in HTML context).
|
||||
let ds_html: String = "<p>" + name_safe + " <" + signer_email + "></p>"
|
||||
+ "<p>Submission #" + sub_id + " - " + event_type + " at " + event_ts + "</p>"
|
||||
+ "<pre style=\"font-family:monospace;font-size:11px;background:#f4f4f4;padding:12px;border-radius:6px\">"
|
||||
+ body_safe + "</pre>"
|
||||
let ds_send: String = send_email(
|
||||
"DocuSeal <no-reply@neurontechnologies.ai>",
|
||||
"will.anderson@neurontechnologies.ai",
|
||||
ds_subject,
|
||||
ds_html,
|
||||
""
|
||||
)
|
||||
println("[docuseal] " + event_type + " " + ds_send)
|
||||
}
|
||||
|
||||
return "{\"ok\":true}"
|
||||
@@ -1316,7 +1399,14 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
if !str_eq(sb_key, "") {
|
||||
let q_safe: String = str_replace(str_replace(question, "\\", "\\\\"), "\"", "\\\"")
|
||||
let a_safe: String = str_replace(str_replace(str_replace(str_replace(answer_plain, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\r", "\\r")
|
||||
let card_row: String = "{\"id\":\"" + id + "\",\"question\":\"" + q_safe + "\",\"answer\":\"" + a_safe + "\"}"
|
||||
// answer_html: marked.js-rendered HTML the client captured,
|
||||
// sanitized server-side. Storing it lets /said render the
|
||||
// same formatted preview as /share/<id>. Empty -> omit the
|
||||
// column (legacy fallback path).
|
||||
let html_sanitized: String = if str_eq(answer_html_raw, "") { "" } else { sanitize_share_html(answer_html_raw) }
|
||||
let h_safe: String = str_replace(str_replace(str_replace(str_replace(html_sanitized, "\\", "\\\\"), "\"", "\\\""), "\n", "\\n"), "\r", "\\r")
|
||||
let html_field: String = if str_eq(html_sanitized, "") { "" } else { ",\"answer_html\":\"" + h_safe + "\"" }
|
||||
let card_row: String = "{\"id\":\"" + id + "\",\"question\":\"" + q_safe + "\",\"answer\":\"" + a_safe + "\"" + html_field + "}"
|
||||
let sb_resp: String = supabase_insert(sb_url, sb_key, "share_cards", card_row)
|
||||
println("[share] supabase insert " + id + " -> " + sb_resp)
|
||||
}
|
||||
@@ -1486,7 +1576,7 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
let cards_json = supabase_get(
|
||||
sb_url,
|
||||
sb_key,
|
||||
"share_cards?select=id,question,answer,score,upvotes,downvotes,created_at&order=score.desc,created_at.desc&limit=100"
|
||||
"share_cards?select=id,question,answer,answer_html,score,upvotes,downvotes,created_at&order=score.desc,created_at.desc&limit=100"
|
||||
)
|
||||
}
|
||||
return gallery_page(cards_json, sb_url, sb_anon)
|
||||
@@ -1630,13 +1720,17 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
let fam_row: String = "{\"parent_email\":\"" + pe_safe + "\",\"parent_user_id\":\"\",\"child_email\":\"" + ce_safe + "\",\"child_dob_year\":" + child_dob_year_str + ",\"status\":\"invited\",\"stripe_subscription_id\":\"" + sub_id + "\",\"invite_token\":\"" + invite_token + "\"}"
|
||||
let fam_insert_resp: String = supabase_insert(fam_sb_url, fam_sb_key, "family_members", fam_row)
|
||||
println("[family/invite] insert -> " + fam_insert_resp)
|
||||
// Send invite email if Resend available
|
||||
let fam_resend_key: String = state_get("__resend_api_key__")
|
||||
if !str_eq(fam_resend_key, "") {
|
||||
let invite_email_body: String = "{\"from\":\"Neuron <no-reply@neurontechnologies.ai>\",\"to\":[\"" + ce_safe + "\"],\"subject\":\"You have been invited to Neuron\",\"text\":\"You have been invited to join Neuron by " + pe_safe + ". Visit https://neurontechnologies.ai/account to set up your account.\"}"
|
||||
let invite_email_resp: String = http_post_auth("https://api.resend.com/emails", fam_resend_key, invite_email_body)
|
||||
println("[family/invite] email -> " + invite_email_resp)
|
||||
}
|
||||
// Send invite email
|
||||
let invite_text: String = "You have been invited to join Neuron by " + parent_email
|
||||
+ ". Visit https://neurontechnologies.ai/account to set up your account."
|
||||
let invite_send: String = send_email(
|
||||
"Neuron <no-reply@neurontechnologies.ai>",
|
||||
child_email,
|
||||
"You have been invited to Neuron",
|
||||
"",
|
||||
invite_text
|
||||
)
|
||||
println("[family/invite] " + invite_send)
|
||||
return "{\"ok\":true,\"invite_token\":\"" + invite_token + "\"}"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user