patx/relay-lang
Implemented
Commit b1903e1 · patx · 2026-02-12T22:10:17-05:00
Implemented
- New upload security config on app state:
- UploadConfig with defaults: max_body_bytes=10 MiB, max_file_bytes=5 MiB, allowed_mime_types=None in src/main.rs:3842.
- Stored in app state and accessor/mutators in src/main.rs:3819, src/main.rs:3965, src/main.rs:3970.
- New API:
- app.uploads(max_body_bytes=None, max_file_bytes=None, allowed_mime_types=None) in src/main.rs:6245.
- Enforcement in request parsing:
- Body-size check in src/main.rs:4456.
- Multipart file-size check and MIME allowlist check in src/main.rs:4412 and src/main.rs:4420.
- Parser now takes app config in src/main.rs:4447.
- HTTP route handler passes app upload config to parser in src/main.rs:4208.
- Multipart form parsing remains rich (request["form"] file dicts with filename/content_type/size/bytes) in src/main.rs:4384.
- Existing explicit template method remains (app.render_template(...)) in src/main.rs:6138.
- Multipart email attachments remain supported (attachments arg) in src/main.rs:5452.
Tests added
- Oversized body rejection: src/main.rs:7087
- Oversized multipart file rejection: src/main.rs:7113
- Disallowed MIME rejection: src/main.rs:7144
- app.uploads(...) config wiring: src/main.rs:7174
Docs/examples updated
- Upload guard feature + app.uploads(...) docs: README.md:50, README.md:1131
- Upload + email example now configures upload limits/allowlist: examples/upload_email_attachment.ry:3
Validation
- cargo check passed.
- cargo test passed: 40 passed, 0 failed.
Comments
No comments yet.
Diff
diff --git a/Cargo.lock b/Cargo.lock
index 13cec8a..b38d38d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -15,6 +15,12 @@ dependencies = [
"zerocopy",
]
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
[[package]]
name = "android_system_properties"
version = "0.1.5"
@@ -30,6 +36,15 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
+[[package]]
+name = "ar_archive_writer"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
+dependencies = [
+ "object",
+]
+
[[package]]
name = "argon2"
version = "0.5.3"
@@ -274,6 +289,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "chumsky"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9"
+dependencies = [
+ "hashbrown 0.14.5",
+ "stacker",
+]
+
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -411,6 +436,22 @@ dependencies = [
"syn 2.0.114",
]
+[[package]]
+name = "email-encoding"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
+dependencies = [
+ "base64 0.22.1",
+ "memchr",
+]
+
+[[package]]
+name = "email_address"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -660,6 +701,16 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+dependencies = [
+ "ahash",
+ "allocator-api2",
+]
+
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1054,6 +1105,34 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+[[package]]
+name = "lettre"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f"
+dependencies = [
+ "async-trait",
+ "base64 0.22.1",
+ "chumsky",
+ "email-encoding",
+ "email_address",
+ "fastrand",
+ "futures-io",
+ "futures-util",
+ "httpdate",
+ "idna 1.1.0",
+ "mime",
+ "nom",
+ "percent-encoding",
+ "quoted_printable",
+ "rustls 0.23.36",
+ "socket2 0.6.2",
+ "tokio",
+ "tokio-rustls 0.26.4",
+ "url",
+ "webpki-roots 1.0.6",
+]
+
[[package]]
name = "libc"
version = "0.2.181"
@@ -1209,6 +1288,23 @@ dependencies = [
"webpki-roots 0.25.4",
]
+[[package]]
+name = "multer"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
+dependencies = [
+ "bytes",
+ "encoding_rs",
+ "futures-util",
+ "http",
+ "httparse",
+ "memchr",
+ "mime",
+ "spin",
+ "version_check",
+]
+
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -1226,6 +1322,15 @@ dependencies = [
"tempfile",
]
+[[package]]
+name = "nom"
+version = "8.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "num-conv"
version = "0.2.0"
@@ -1241,6 +1346,15 @@ dependencies = [
"autocfg",
]
+[[package]]
+name = "object"
+version = "0.37.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -1401,6 +1515,16 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "psm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
+dependencies = [
+ "ar_archive_writer",
+ "cc",
+]
+
[[package]]
name = "quinn"
version = "0.11.9"
@@ -1465,6 +1589,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "quoted_printable"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
+
[[package]]
name = "r-efi"
version = "5.3.0"
@@ -1556,8 +1686,10 @@ dependencies = [
"futures",
"html-escape",
"indexmap",
+ "lettre",
"minijinja",
"mongodb",
+ "multer",
"reqwest",
"serde",
"serde_json",
@@ -1697,6 +1829,7 @@ version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
+ "log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -2016,12 +2149,31 @@ dependencies = [
"windows-sys 0.60.2",
]
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+[[package]]
+name = "stacker"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "libc",
+ "psm",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "stringprep"
version = "0.1.5"
@@ -2862,6 +3014,15 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
[[package]]
name = "windows-sys"
version = "0.60.2"
diff --git a/Cargo.toml b/Cargo.toml
index d893180..95edd9b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -21,3 +21,5 @@ futures = "0.3"
minijinja = "2"
url = "2"
argon2 = "0.5"
+lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1-rustls-tls"] }
+multer = "3"
diff --git a/README.md b/README.md
index d929b08..52d36d0 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ Relay is designed for building high-performance web services, APIs, and network
- [Async & Concurrency](#async--concurrency)
- [File System](#file-system)
- [HTTP Client](#http-client)
+ - [Email (SMTP)](#email-smtp)
- [Web Server](#web-server)
- [Database (MongoDB)](#database-mongodb)
- [Examples](#examples)
@@ -44,6 +45,9 @@ Relay is designed for building high-performance web services, APIs, and network
- **Static File Serving**: Mount directories directly in your web app (`app.static("/assets", "./public")`)
- **Middleware Hooks**: Run request middleware before handlers (`app.use(auth_middleware)`)
- **HTTP Client**: Async HTTP client with `get/post/put/patch/delete` and request/response headers
+- **Email (SMTP)**: Async SMTP mail sending with secure-by-default TLS and template helpers
+- **Multipart Uploads**: Parse `multipart/form-data` bodies with file metadata/bytes
+- **Upload Guards**: Configure max request/file size limits and MIME allowlists via `app.uploads(...)`
- **MongoDB Integration**: Native async MongoDB support
- **Session Management**: Built-in session handling with HttpOnly cookies
- **Pluggable Session Backends**: Use in-memory sessions or custom load/save callbacks
@@ -713,6 +717,102 @@ data = resp.json()
print(data["login"]) // octocat
```
+### Email (SMTP)
+
+#### `Email(host, port=587, username=None, password=None, from=None, tls="starttls")`
+Create an SMTP email client instance.
+
+```relay
+email = Email(
+ "smtp.example.com",
+ 587,
+ "smtp-user",
+ "smtp-pass",
+ "Relay <[email protected]>",
+ tls="starttls"
+)
+```
+
+TLS modes:
+- `"starttls"` (default) - requires STARTTLS upgrade
+- `"wrapper"` - implicit TLS/SMTPS (commonly port 465)
+- `"insecure"` - plain SMTP (only for local/dev)
+
+#### `email.send(to, subject, text=None, html=None, cc=None, bcc=None, reply_to=None, from=None, headers=None, attachments=None)`
+**Returns:** `Deferred<dict>`
+
+Send an email asynchronously over SMTP.
+
+- `to`, `cc`, `bcc` accept `str` or `list[str]`
+- at least one of `text` or `html` is required
+- `from` can be passed per-call or configured in `Email(...)`
+- `attachments` accepts a single attachment dict or list of attachment dicts
+
+```relay
+email = Email(
+ host="smtp.example.com",
+ username="smtp-user",
+ password="smtp-pass",
+ from="Relay <[email protected]>"
+)
+
+result = email.send(
+ to=["[email protected]"],
+ subject="Welcome",
+ text="Welcome to Relay!"
+)
+
+print(result["ok"]) // True
+print(result["smtp_code"]) // e.g. 250
+```
+
+Attachment dict format:
+- `filename` (required) - attachment file name
+- `bytes` or `content` or `data` (required) - file payload (`bytes`, `str`, or `list[int]`)
+- `content_type` (optional) - MIME type (default: `application/octet-stream`)
+
+```relay
+result = email.send(
+ to="[email protected]",
+ subject="Monthly report",
+ text="See attached report.",
+ attachments=[
+ {
+ "filename": "report.txt",
+ "content_type": "text/plain",
+ "bytes": "Report body"
+ }
+ ]
+)
+```
+
+Send result fields:
+- `ok` - always `True` on success
+- `transport` - `"smtp"`
+- `host` / `port` - resolved transport target
+- `message_id` - message id when available, else `None`
+- `envelope_to` - recipient list used for SMTP envelope
+- `smtp_code` / `smtp_message` - provider response details when available
+
+#### `email.render(template, data=None)`
+**Returns:** `string`
+
+Render an email template string with MiniJinja variables.
+
+```relay
+body = email.render("Hi {{ name }}, welcome!", {"name": "Ada"})
+```
+
+#### `email.render_file(path, data=None)`
+**Returns:** `Deferred<string>`
+
+Load and render a template file asynchronously.
+
+```relay
+html = email.render_file("templates/welcome.html", {"name": "Ada"})
+email.send(to="[email protected]", subject="Welcome", html=html)
+```
+
### Web Server
#### `WebApp()`
@@ -914,6 +1014,13 @@ fn debug_request()
- `request_id` - Request identifier string
- `query` - Query parameters (dict)
- `form` - Parsed form body fields (dict, when present)
+ - URL-encoded fields become strings
+ - Multipart text fields become strings
+ - Multipart file fields become dicts:
+ - `filename` (`str` or `None`)
+ - `content_type` (`str` or `None`)
+ - `size` (`int`)
+ - `bytes` (`bytes`)
- `headers` - Request headers (dict)
- `cookies` - Cookies (dict)
- `json` - Parsed JSON body (if present)
@@ -1016,6 +1123,25 @@ fn save_session(sid, data)
app.session_backend(load_session, save_session)
```
+#### Upload Limits and MIME Allowlist
+
+Configure upload safety controls per app:
+
+```relay
+app.uploads(
+ max_body_bytes=10 * 1024 * 1024, // default 10 MiB
+ max_file_bytes=5 * 1024 * 1024, // default 5 MiB per file field
+ allowed_mime_types=["image/png", "application/pdf"] // optional
+)
+```
+
+Parameters:
+- `max_body_bytes` - Maximum raw request body size accepted
+- `max_file_bytes` - Maximum size for each multipart file field
+- `allowed_mime_types` - Optional MIME allowlist for multipart file fields (`None` disables allowlist)
+
+When limits are exceeded or a MIME type is disallowed, Relay returns `400 bad_request`.
+
#### Response Types
Handlers can return various types:
@@ -1167,6 +1293,13 @@ fn index()
Templates can reference values in the current scope and support MiniJinja expressions/filters.
+Use `app.render_template(...)` when you want explicit template rendering without relying on implicit `{{ ... }}` string evaluation:
+
+```relay
+app = WebApp()
+html = app.render_template("<h1>Hello {{ name }}</h1>", {"name": "Ada"})
+```
+
#### `WebServer()`
Create a web server instance.
@@ -1625,6 +1758,32 @@ print("All tasks started, continuing...")
// Tasks run in background
```
+### 11. SMTP Welcome Email with Templates
+
+```relay
+email = Email(
+ host="smtp.example.com",
+ username="smtp-user",
+ password="smtp-pass",
+ from="Relay <[email protected]>"
+)
+
+fn send_welcome(name, to)
+ html = email.render(
+ "<h1>Welcome {{ name }}</h1><p>Thanks for joining Relay.</p>",
+ {"name": name}
+ )
+ return email.send(
+ to=to,
+ subject="Welcome to Relay",
+ text="Welcome " + name + "!",
+ html=html
+ )
+
+result = send_welcome("Ada", "[email protected]")
+print("Email queued:", result["ok"])
+```
+
## How It Works
@@ -1635,6 +1794,7 @@ Relay is built on:
- **Rust**: The interpreter is written in Rust for performance and safety
- **Tokio**: Async runtime for non-blocking I/O
- **Axum**: High-performance web framework for the built-in server
+- **Lettre**: Async SMTP transport for built-in email delivery
- **MongoDB driver**: Native async MongoDB support
### Compilation Pipeline
diff --git a/examples/email_smtp.ry b/examples/email_smtp.ry
new file mode 100644
index 0000000..02304dc
--- /dev/null
+++ b/examples/email_smtp.ry
@@ -0,0 +1,22 @@
+email = Email(
+ host="smtp.example.com",
+ username="smtp-user",
+ password="smtp-pass",
+ from="Relay <[email protected]>"
+)
+
+fn send_welcome(name, to)
+ html = email.render(
+ "<h1>Welcome {{ name }}</h1><p>Your account is ready.</p>",
+ {"name": name}
+ )
+
+ return email.send(
+ to=to,
+ subject="Welcome to Relay",
+ text="Welcome " + name + "!",
+ html=html
+ )
+
+result = send_welcome("Ada", "[email protected]")
+print("SMTP send result:", result)
diff --git a/examples/upload_email_attachment.ry b/examples/upload_email_attachment.ry
new file mode 100644
index 0000000..c42a2fa
--- /dev/null
+++ b/examples/upload_email_attachment.ry
@@ -0,0 +1,39 @@
+app = WebApp()
+server = WebServer()
+app.uploads(
+ max_body_bytes=10 * 1024 * 1024,
+ max_file_bytes=5 * 1024 * 1024,
+ allowed_mime_types=["text/plain", "application/pdf"]
+)
+
+email = Email(
+ host="smtp.example.com",
+ username="smtp-user",
+ password="smtp-pass",
+ from="Relay <[email protected]>"
+)
+
[email protected]("/")
+fn upload_page()
+ return app.render_template(
+ "<h1>Upload</h1><form method=\"post\" enctype=\"multipart/form-data\" action=\"/upload\">\
+<input type=\"email\" name=\"to\" placeholder=\"[email protected]\" />\
+<input type=\"file\" name=\"file\" />\
+<button type=\"submit\">Send</button></form>",
+ {}
+ )
+
[email protected]("/upload")
+fn upload(to, file)
+ if (file == None)
+ return HTTPError(400, "missing_file", "file upload is required")
+
+ result = email.send(
+ to=to,
+ subject="Uploaded file",
+ text="Your uploaded file is attached.",
+ attachments=[file]
+ )
+ return {"ok": result["ok"], "smtp_code": result["smtp_code"]}
+
+server.run(app)
diff --git a/src/main.rs b/src/main.rs
index b328f20..1e7e9ae 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -37,6 +37,14 @@ use argon2::{
};
use futures::{stream::TryStreamExt, SinkExt, StreamExt};
use indexmap::IndexMap;
+use lettre::{
+ message::{
+ header as mail_header, Attachment as MailAttachment, Mailbox, Message as MailMessage,
+ MultiPart, SinglePart,
+ },
+ transport::smtp::authentication::Credentials,
+ AsyncSmtpTransport, AsyncTransport, Tokio1Executor,
+};
use minijinja::Environment;
use mongodb::{
bson::{self, oid::ObjectId, Bson, Document},
@@ -1373,6 +1381,7 @@ enum Object {
WebServer(WebServerHandle),
Http(HttpHandle),
HttpResponse(HttpResponseHandle),
+ Email(EmailHandle),
AuthStore(AuthStoreHandle),
MongoClient(MongoClientHandle),
MongoDatabase(MongoDatabaseHandle),
@@ -2056,6 +2065,10 @@ impl Evaluator {
let is_spawn_ident = matches!(&**callee, Expr::Ident(name) if name == "spawn");
let is_sleep_ident = matches!(&**callee, Expr::Ident(name) if name == "sleep");
let is_print_ident = matches!(&**callee, Expr::Ident(name) if name == "print");
+ let is_explicit_template_method = matches!(
+ &**callee,
+ Expr::Member { name, .. } if name == "render_template" || name == "render"
+ );
let c = self
.resolve_deferred_only(self.eval_expr(callee).await?)
@@ -2070,7 +2083,14 @@ impl Evaluator {
a.push(Value::Thunk(Arc::new(Thunk::new(x.clone(), env))));
continue;
}
- let v = self.eval_expr(x).await?;
+ let v = if is_explicit_template_method && idx == 0 {
+ match x {
+ Expr::Str(s) => Value::Str(s.clone()),
+ _ => self.eval_expr(x).await?,
+ }
+ } else {
+ self.eval_expr(x).await?
+ };
let v = if is_spawn_ident {
v
} else {
@@ -2082,7 +2102,14 @@ impl Evaluator {
// kwargs
let mut kw = Vec::new();
for (k, x) in kwargs {
- let v = self.eval_expr(x).await?;
+ let v = if is_explicit_template_method && k == "template" {
+ match x {
+ Expr::Str(s) => Value::Str(s.clone()),
+ _ => self.eval_expr(x).await?,
+ }
+ } else {
+ self.eval_expr(x).await?
+ };
let v = if is_spawn_ident {
v
} else {
@@ -3322,6 +3349,15 @@ fn install_stdlib(env: &mut Env, evaluator: Arc<Evaluator>) -> RResult<()> {
Box::pin(async move { Ok(Value::Obj(Object::Http(HttpHandle::new()))) })
})),
);
+ env.set(
+ "Email",
+ Value::Builtin(Arc::new(|args, kwargs| {
+ Box::pin(async move {
+ let handle = EmailHandle::from_constructor_args(&args, &kwargs)?;
+ Ok(Value::Obj(Object::Email(handle)))
+ })
+ })),
+ );
env.set(
"auth_hash_password",
@@ -3440,6 +3476,122 @@ fn expect_int(args: &[Value], i: usize, sig: &str) -> RResult<i64> {
}
}
+fn kwarg_or_arg(kwargs: &[(String, Value)], args: &[Value], name: &str, index: usize) -> Option<Value> {
+ kwargs
+ .iter()
+ .find(|(k, _)| k == name)
+ .map(|(_, v)| v.clone())
+ .or_else(|| args.get(index).cloned())
+}
+
+fn expect_str_value(v: Value, sig: &str, field: &str) -> RResult<String> {
+ match v {
+ Value::Str(s) => Ok(s),
+ Value::Json(J::String(s)) => Ok(s),
+ _ => Err(RelayError::Type(format!("{sig} {field} must be str"))),
+ }
+}
+
+fn parse_optional_str_value(v: Option<Value>, sig: &str, field: &str) -> RResult<Option<String>> {
+ match v {
+ None | Some(Value::None) => Ok(None),
+ Some(other) => Ok(Some(expect_str_value(other, sig, field)?)),
+ }
+}
+
+fn parse_u16_value(v: Value, sig: &str, field: &str) -> RResult<u16> {
+ let n = match v {
+ Value::Int(i) => i,
+ Value::Float(f) => f as i64,
+ Value::Str(s) => s
+ .trim()
+ .parse::<i64>()
+ .map_err(|_| RelayError::Type(format!("{sig} {field} must be int")))?,
+ _ => return Err(RelayError::Type(format!("{sig} {field} must be int"))),
+ };
+ if !(0..=65535).contains(&n) {
+ return Err(RelayError::Type(format!("{sig} {field} must be in range 0..65535")));
+ }
+ Ok(n as u16)
+}
+
+fn parse_usize_value(v: Value, sig: &str, field: &str) -> RResult<usize> {
+ let n = match v {
+ Value::Int(i) => i,
+ Value::Float(f) => f as i64,
+ Value::Str(s) => s
+ .trim()
+ .parse::<i64>()
+ .map_err(|_| RelayError::Type(format!("{sig} {field} must be int")))?,
+ _ => return Err(RelayError::Type(format!("{sig} {field} must be int"))),
+ };
+ if n <= 0 {
+ return Err(RelayError::Type(format!(
+ "{sig} {field} must be a positive integer"
+ )));
+ }
+ Ok(n as usize)
+}
+
+fn parse_optional_mime_allowlist(
+ v: Option<Value>,
+ sig: &str,
+ field: &str,
+) -> RResult<Option<HashSet<String>>> {
+ let Some(value) = v else {
+ return Ok(None);
+ };
+ match value {
+ Value::None => Ok(None),
+ Value::Str(s) => {
+ let mime = s.trim().to_ascii_lowercase();
+ if mime.is_empty() {
+ return Ok(None);
+ }
+ Ok(Some(HashSet::from([mime])))
+ }
+ Value::List(items) => {
+ let mut set = HashSet::new();
+ for item in items {
+ let mime = expect_str_value(item, sig, field)?
+ .trim()
+ .to_ascii_lowercase();
+ if !mime.is_empty() {
+ set.insert(mime);
+ }
+ }
+ if set.is_empty() {
+ Ok(None)
+ } else {
+ Ok(Some(set))
+ }
+ }
+ Value::Json(J::Array(items)) => {
+ parse_optional_mime_allowlist(Some(Value::List(items.iter().map(json_to_value).collect())), sig, field)
+ }
+ _ => Err(RelayError::Type(format!(
+ "{sig} {field} must be str or list[str]"
+ ))),
+ }
+}
+
+fn parse_string_list_value(v: Value, sig: &str, field: &str) -> RResult<Vec<String>> {
+ match v {
+ Value::None => Ok(vec![]),
+ Value::Str(s) => Ok(vec![s]),
+ Value::Json(J::String(s)) => Ok(vec![s]),
+ Value::List(items) => items
+ .into_iter()
+ .map(|item| expect_str_value(item, sig, field))
+ .collect(),
+ Value::Json(J::Array(items)) => items
+ .iter()
+ .map(|item| expect_str_value(json_to_value(item), sig, field))
+ .collect(),
+ _ => Err(RelayError::Type(format!("{sig} {field} must be str or list[str]"))),
+ }
+}
+
fn expect_object_like(v: Value, sig: &str) -> RResult<IndexMap<String, Value>> {
match v {
Value::Dict(m) => Ok(m),
@@ -3664,6 +3816,7 @@ struct AppState {
static_mounts: Vec<StaticMount>,
sessions: HashMap<String, IndexMap<String, Value>>,
session_config: SessionConfig,
+ upload_config: UploadConfig,
openapi: Option<OpenApiConfig>,
session_backend: SessionBackendConfig,
}
@@ -3685,6 +3838,23 @@ impl Default for SessionConfig {
}
}
+#[derive(Clone)]
+struct UploadConfig {
+ max_body_bytes: usize,
+ max_file_bytes: usize,
+ allowed_mime_types: Option<HashSet<String>>,
+}
+
+impl Default for UploadConfig {
+ fn default() -> Self {
+ Self {
+ max_body_bytes: 10 * 1024 * 1024, // 10 MiB
+ max_file_bytes: 5 * 1024 * 1024, // 5 MiB
+ allowed_mime_types: None,
+ }
+ }
+}
+
#[derive(Clone)]
struct OpenApiConfig {
title: String,
@@ -3699,6 +3869,7 @@ impl Default for AppState {
static_mounts: Vec::new(),
sessions: HashMap::new(),
session_config: SessionConfig::default(),
+ upload_config: UploadConfig::default(),
openapi: None,
session_backend: SessionBackendConfig::Memory,
}
@@ -3791,6 +3962,16 @@ impl WebAppHandle {
st.session_config.clone()
}
+ fn set_upload_config(&self, cfg: UploadConfig) {
+ let mut st = self.inner.lock().unwrap();
+ st.upload_config = cfg;
+ }
+
+ fn upload_config(&self) -> UploadConfig {
+ let st = self.inner.lock().unwrap();
+ st.upload_config.clone()
+ }
+
fn set_openapi(&self, cfg: OpenApiConfig) {
let mut st = self.inner.lock().unwrap();
st.openapi = Some(cfg);
@@ -3868,7 +4049,7 @@ struct RequestParts {
path_params: HashMap<String, String>,
query: HashMap<String, String>,
json: Option<J>,
- form: HashMap<String, String>,
+ form: HashMap<String, Value>,
headers: HashMap<String, String>,
cookies: HashMap<String, String>,
session_id: Option<String>,
@@ -4024,7 +4205,25 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
.cloned()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(new_request_id);
- let (json_body, form_body) = parse_request_body(&headers, &body);
+ let upload_config = app_handle.upload_config();
+ let (json_body, form_body) =
+ match parse_request_body(&headers, body, &upload_config).await {
+ Ok(parts) => parts,
+ Err(e) => {
+ let error = error_response(
+ 400,
+ "bad_request",
+ &e.to_string(),
+ None,
+ Some(request_id),
+ );
+ return Ok::<_, (StatusCode, String)>(
+ value_to_axum_response(Value::Response(error))
+ .await
+ .into_response(),
+ );
+ }
+ };
let req = RequestParts {
method: method.to_string(),
@@ -4147,30 +4346,149 @@ async fn run_app(app: WebAppHandle) -> RResult<()> {
Ok(())
}
-fn parse_request_body(headers: &HeaderMap, body: &[u8]) -> (Option<J>, HashMap<String, String>) {
+fn push_form_value(form: &mut HashMap<String, Value>, key: String, value: Value) {
+ use std::collections::hash_map::Entry;
+ match form.entry(key) {
+ Entry::Vacant(slot) => {
+ slot.insert(value);
+ }
+ Entry::Occupied(mut occ) => match occ.get_mut() {
+ Value::List(items) => items.push(value),
+ existing => {
+ let previous = existing.clone();
+ *existing = Value::List(vec![previous, value]);
+ }
+ },
+ }
+}
+
+fn build_uploaded_file_value(
+ filename: Option<String>,
+ content_type: Option<String>,
+ bytes: Vec<u8>,
+) -> Value {
+ let mut file = IndexMap::new();
+ file.insert(
+ "filename".into(),
+ filename.map(Value::Str).unwrap_or(Value::None),
+ );
+ file.insert(
+ "content_type".into(),
+ content_type.map(Value::Str).unwrap_or(Value::None),
+ );
+ file.insert("size".into(), Value::Int(bytes.len() as i64));
+ file.insert("bytes".into(), Value::Bytes(bytes));
+ Value::Dict(file)
+}
+
+async fn parse_multipart_form_data(
+ content_type: &str,
+ body: Bytes,
+ upload_config: &UploadConfig,
+) -> RResult<HashMap<String, Value>> {
+ let boundary = multer::parse_boundary(content_type)
+ .map_err(|e| RelayError::Type(format!("multipart/form-data boundary parse failed: {e}")))?;
+
+ let stream = futures::stream::once(async move { Ok::<Bytes, std::io::Error>(body) });
+ let mut multipart = multer::Multipart::new(stream, boundary);
+ let mut form = HashMap::new();
+
+ while let Some(field) = multipart
+ .next_field()
+ .await
+ .map_err(|e| RelayError::Type(format!("multipart/form-data parse failed: {e}")))?
+ {
+ let Some(name) = field.name().map(|s| s.to_string()) else {
+ continue;
+ };
+ let filename = field.file_name().map(|s| s.to_string());
+ let content_type = field.content_type().map(|s| s.to_string());
+ let bytes = field
+ .bytes()
+ .await
+ .map_err(|e| RelayError::Type(format!("multipart field read failed: {e}")))?;
+
+ let value = if filename.is_some() {
+ if bytes.len() > upload_config.max_file_bytes {
+ return Err(RelayError::Type(format!(
+ "multipart file field '{name}' exceeds max_file_bytes={} (got {} bytes)",
+ upload_config.max_file_bytes,
+ bytes.len()
+ )));
+ }
+
+ if let Some(allowed) = &upload_config.allowed_mime_types {
+ let normalized = content_type
+ .as_deref()
+ .map(|v| v.trim().to_ascii_lowercase())
+ .unwrap_or_default();
+ if normalized.is_empty() || !allowed.contains(&normalized) {
+ return Err(RelayError::Type(format!(
+ "multipart file field '{name}' content type '{}' is not allowed",
+ if normalized.is_empty() {
+ "<missing>"
+ } else {
+ normalized.as_str()
+ }
+ )));
+ }
+ }
+
+ build_uploaded_file_value(filename, content_type, bytes.to_vec())
+ } else {
+ Value::Str(String::from_utf8_lossy(&bytes).to_string())
+ };
+ push_form_value(&mut form, name, value);
+ }
+
+ Ok(form)
+}
+
+async fn parse_request_body(
+ headers: &HeaderMap,
+ body: Bytes,
+ upload_config: &UploadConfig,
+) -> RResult<(Option<J>, HashMap<String, Value>)> {
if body.is_empty() {
- return (None, HashMap::new());
+ return Ok((None, HashMap::new()));
+ }
+
+ if body.len() > upload_config.max_body_bytes {
+ return Err(RelayError::Type(format!(
+ "request body exceeds max_body_bytes={} (got {} bytes)",
+ upload_config.max_body_bytes,
+ body.len()
+ )));
}
- let ct = headers
+ let ct_raw = headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
- .to_ascii_lowercase();
+ .to_string();
+ let ct = ct_raw.to_ascii_lowercase();
if ct.contains("application/json") {
- let json = serde_json::from_slice::<J>(body).ok();
- return (json, HashMap::new());
+ if let Ok(json) = serde_json::from_slice::<J>(&body) {
+ return Ok((Some(json), HashMap::new()));
+ }
+ return Ok((None, HashMap::new()));
}
if ct.contains("application/x-www-form-urlencoded") {
- let form = url::form_urlencoded::parse(body)
- .into_owned()
- .collect::<HashMap<String, String>>();
- return (None, form);
+ let mut form = HashMap::new();
+ for (k, v) in url::form_urlencoded::parse(&body).into_owned() {
+ push_form_value(&mut form, k, Value::Str(v));
+ }
+ return Ok((None, form));
}
- (None, HashMap::new())
+ if ct.contains("multipart/form-data") {
+ let form = parse_multipart_form_data(&ct_raw, body, upload_config).await?;
+ return Ok((None, form));
+ }
+
+ Ok((None, HashMap::new()))
}
fn parse_cookie_header(raw: Option<String>) -> HashMap<String, String> {
@@ -4838,100 +5156,628 @@ impl HttpResponseHandle {
}
}
+// ========================= Email Client (SMTP) =========================
+
+type EmailSendFut = Pin<Box<dyn Future<Output = RResult<EmailSendMetadata>> + Send>>;
+type EmailSender = Arc<dyn Fn(MailMessage) -> EmailSendFut + Send + Sync>;
+
+#[derive(Clone, Copy)]
+enum EmailTlsMode {
+ StartTls,
+ Wrapper,
+ Insecure,
+}
+
+impl EmailTlsMode {
+ fn parse(raw: &str) -> RResult<Self> {
+ match raw.trim().to_lowercase().as_str() {
+ "" | "starttls" => Ok(Self::StartTls),
+ "wrapper" | "smtps" => Ok(Self::Wrapper),
+ "insecure" => Ok(Self::Insecure),
+ other => Err(RelayError::Type(format!(
+ "Email(..., tls=...) expects one of: starttls, wrapper, insecure (got '{other}')"
+ ))),
+ }
+ }
+}
+
#[derive(Clone)]
-enum AuthStoreBackend {
- Memory(Arc<tokio::sync::Mutex<HashMap<String, String>>>),
- Callback {
- load_fn: String,
- save_fn: String,
- evaluator: Arc<Evaluator>,
- },
+struct EmailConfig {
+ host: String,
+ port: u16,
+ username: Option<String>,
+ password: Option<String>,
+ default_from: Option<String>,
+ tls_mode: EmailTlsMode,
+}
+
+#[derive(Clone, Default)]
+struct EmailSendMetadata {
+ message_id: Option<String>,
+ smtp_code: Option<i64>,
+ smtp_message: Option<String>,
+}
+
+struct PreparedEmail {
+ message: MailMessage,
+ envelope_to: Vec<String>,
+ message_id: Option<String>,
}
#[derive(Clone)]
-struct AuthStoreHandle {
- backend: AuthStoreBackend,
+struct EmailAttachmentSpec {
+ filename: String,
+ content_type: String,
+ bytes: Vec<u8>,
}
-impl AuthStoreHandle {
- fn memory() -> Self {
- Self {
- backend: AuthStoreBackend::Memory(Arc::new(tokio::sync::Mutex::new(HashMap::new()))),
- }
+#[derive(Clone)]
+struct EmailHandle {
+ config: EmailConfig,
+ sender: EmailSender,
+}
+
+fn parse_mailbox(raw: String, sig: &str, field: &str) -> RResult<Mailbox> {
+ let trimmed = raw.trim();
+ if trimmed.is_empty() {
+ return Err(RelayError::Type(format!("{sig} {field} cannot be empty")));
}
+ trimmed.parse::<Mailbox>().map_err(|e| {
+ RelayError::Type(format!(
+ "{sig} {field} has invalid mailbox '{trimmed}': {e}"
+ ))
+ })
+}
- fn callback(evaluator: Arc<Evaluator>, load_fn: String, save_fn: String) -> Self {
- Self {
- backend: AuthStoreBackend::Callback {
- load_fn,
- save_fn,
- evaluator,
- },
- }
+fn parse_optional_mailbox(v: Option<Value>, sig: &str, field: &str) -> RResult<Option<Mailbox>> {
+ let Some(raw) = parse_optional_str_value(v, sig, field)? else {
+ return Ok(None);
+ };
+ Ok(Some(parse_mailbox(raw, sig, field)?))
+}
+
+fn parse_mailbox_list(v: Value, sig: &str, field: &str) -> RResult<Vec<Mailbox>> {
+ parse_string_list_value(v, sig, field)?
+ .into_iter()
+ .map(|raw| parse_mailbox(raw, sig, field))
+ .collect()
+}
+
+fn parse_template_locals(v: Option<Value>, sig: &str) -> RResult<HashMap<String, Value>> {
+ match v.unwrap_or(Value::None) {
+ Value::None => Ok(HashMap::new()),
+ Value::Dict(m) => Ok(m.into_iter().collect()),
+ Value::Json(J::Object(obj)) => Ok(obj
+ .iter()
+ .map(|(k, vv)| (k.clone(), json_to_value(vv)))
+ .collect()),
+ _ => Err(RelayError::Type(format!(
+ "{sig} data must be dict/json object"
+ ))),
}
+}
- async fn load_hash(&self, username: String) -> RResult<Option<String>> {
- match &self.backend {
- AuthStoreBackend::Memory(store) => Ok(store.lock().await.get(&username).cloned()),
- AuthStoreBackend::Callback {
- load_fn,
- evaluator,
- ..
- } => {
- let out = evaluator
- .call_named_function(load_fn, vec![Value::Str(username)])
- .await?;
- match out {
- Value::None => Ok(None),
- Value::Str(s) => Ok(Some(s)),
- other => Err(RelayError::Type(format!(
- "auth load callback must return str/None, got {}",
- value_type_name(&other)
- ))),
+fn parse_attachment_bytes(v: Value, sig: &str) -> RResult<Vec<u8>> {
+ match v {
+ Value::Bytes(b) => Ok(b),
+ Value::Str(s) => Ok(s.into_bytes()),
+ Value::Json(J::String(s)) => Ok(s.into_bytes()),
+ Value::List(values) => {
+ let mut out = Vec::with_capacity(values.len());
+ for item in values {
+ let n = match item {
+ Value::Int(i) => i,
+ Value::Float(f) => f as i64,
+ _ => {
+ return Err(RelayError::Type(format!(
+ "{sig} attachments bytes list must contain ints"
+ )))
+ }
+ };
+ if !(0..=255).contains(&n) {
+ return Err(RelayError::Type(format!(
+ "{sig} attachments bytes list values must be in range 0..255"
+ )));
}
+ out.push(n as u8);
}
+ Ok(out)
}
+ Value::Json(J::Array(values)) => parse_attachment_bytes(Value::List(
+ values.iter().map(json_to_value).collect(),
+ ), sig),
+ _ => Err(RelayError::Type(format!(
+ "{sig} attachment content must be bytes, str, or list[int]"
+ ))),
}
+}
- async fn save_hash(&self, username: String, hash: String) -> RResult<()> {
- match &self.backend {
- AuthStoreBackend::Memory(store) => {
- store.lock().await.insert(username, hash);
- Ok(())
- }
- AuthStoreBackend::Callback {
- save_fn,
- evaluator,
- ..
- } => {
- let _ = evaluator
- .call_named_function(save_fn, vec![Value::Str(username), Value::Str(hash)])
- .await?;
- Ok(())
- }
- }
+fn parse_email_attachment(v: Value, sig: &str) -> RResult<EmailAttachmentSpec> {
+ let mut map = expect_object_like(v, sig)?;
+ let filename = map
+ .remove("filename")
+ .ok_or_else(|| RelayError::Type(format!("{sig} attachment missing filename")))?;
+ let filename = expect_str_value(filename, sig, "attachment.filename")?;
+
+ let content_type = parse_optional_str_value(
+ map.remove("content_type"),
+ sig,
+ "attachment.content_type",
+ )?
+ .unwrap_or_else(|| "application/octet-stream".to_string());
+
+ let body_value = map
+ .remove("bytes")
+ .or_else(|| map.remove("content"))
+ .or_else(|| map.remove("data"))
+ .ok_or_else(|| {
+ RelayError::Type(format!(
+ "{sig} attachment requires bytes/content/data field"
+ ))
+ })?;
+ let bytes = parse_attachment_bytes(body_value, sig)?;
+
+ Ok(EmailAttachmentSpec {
+ filename,
+ content_type,
+ bytes,
+ })
+}
+
+fn parse_email_attachments(v: Option<Value>, sig: &str) -> RResult<Vec<EmailAttachmentSpec>> {
+ let Some(v) = v else {
+ return Ok(vec![]);
+ };
+ match v {
+ Value::None => Ok(vec![]),
+ Value::Dict(_) | Value::Json(J::Object(_)) => Ok(vec![parse_email_attachment(v, sig)?]),
+ Value::List(items) => items
+ .into_iter()
+ .map(|item| parse_email_attachment(item, sig))
+ .collect(),
+ Value::Json(J::Array(items)) => items
+ .iter()
+ .map(|item| parse_email_attachment(json_to_value(item), sig))
+ .collect(),
+ _ => Err(RelayError::Type(format!(
+ "{sig} attachments must be attachment dict or list[attachment]"
+ ))),
}
+}
- fn get_member(&self, name: &str) -> Value {
- match name {
- "set_hash" => {
- let store = self.clone();
- Value::Builtin(Arc::new(move |args, _| {
- let store = store.clone();
- Box::pin(async move {
- let username = expect_str(&args, 0, "auth_store.set_hash(username, hash)")?;
- let hash = expect_str(&args, 1, "auth_store.set_hash(username, hash)")?;
- store.save_hash(username, hash).await?;
- Ok(Value::None)
- })
- }))
+fn smtp_sender_for_config(config: &EmailConfig) -> EmailSender {
+ let host = config.host.clone();
+ let port = config.port;
+ let username = config.username.clone();
+ let password = config.password.clone();
+ let tls_mode = config.tls_mode;
+
+ Arc::new(move |message: MailMessage| {
+ let host = host.clone();
+ let username = username.clone();
+ let password = password.clone();
+ Box::pin(async move {
+ let mut builder = match tls_mode {
+ EmailTlsMode::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(
+ host.as_str(),
+ )
+ .map_err(|e| RelayError::Runtime(format!("email transport setup failed: {e}")))?,
+ EmailTlsMode::Wrapper => AsyncSmtpTransport::<Tokio1Executor>::relay(
+ host.as_str(),
+ )
+ .map_err(|e| RelayError::Runtime(format!("email transport setup failed: {e}")))?,
+ EmailTlsMode::Insecure => {
+ AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str())
+ }
+ };
+ builder = builder.port(port);
+
+ if let (Some(user), Some(pass)) = (username, password) {
+ builder = builder.credentials(Credentials::new(user, pass));
}
- "get_hash" => {
- let store = self.clone();
- Value::Builtin(Arc::new(move |args, _| {
- let store = store.clone();
- Box::pin(async move {
- let username = expect_str(&args, 0, "auth_store.get_hash(username)")?;
+
+ let transport = builder.build();
+ let response = transport
+ .send(message)
+ .await
+ .map_err(|e| RelayError::Runtime(format!("email send failed: {e}")))?;
+
+ let smtp_code = response.code().to_string().parse::<i64>().ok();
+ let smtp_message_lines = response
+ .message()
+ .map(|line| line.to_string())
+ .collect::<Vec<_>>();
+ let smtp_message = if smtp_message_lines.is_empty() {
+ None
+ } else {
+ Some(smtp_message_lines.join("\n"))
+ };
+
+ Ok(EmailSendMetadata {
+ message_id: None,
+ smtp_code,
+ smtp_message,
+ })
+ })
+ })
+}
+
+impl EmailHandle {
+ fn new(config: EmailConfig) -> Self {
+ let sender = smtp_sender_for_config(&config);
+ Self { config, sender }
+ }
+
+ fn from_constructor_args(args: &[Value], kwargs: &[(String, Value)]) -> RResult<Self> {
+ let sig = "Email(host, port=587, username=None, password=None, from=None, tls=\"starttls\")";
+
+ let host = kwarg_or_arg(kwargs, args, "host", 0).ok_or_else(|| {
+ RelayError::Type(format!("{sig} missing host"))
+ })?;
+ let host = expect_str_value(host, sig, "host")?;
+
+ let port = kwarg_or_arg(kwargs, args, "port", 1).unwrap_or(Value::Int(587));
+ let port = parse_u16_value(port, sig, "port")?;
+
+ let username = parse_optional_str_value(kwarg_or_arg(kwargs, args, "username", 2), sig, "username")?;
+ let password = parse_optional_str_value(kwarg_or_arg(kwargs, args, "password", 3), sig, "password")?;
+ let default_from = parse_optional_str_value(kwarg_or_arg(kwargs, args, "from", 4), sig, "from")?;
+
+ let tls_raw = parse_optional_str_value(kwarg_or_arg(kwargs, args, "tls", 5), sig, "tls")?
+ .unwrap_or_else(|| "starttls".to_string());
+ let tls_mode = EmailTlsMode::parse(&tls_raw)?;
+
+ if username.is_some() ^ password.is_some() {
+ return Err(RelayError::Type(format!(
+ "{sig} username and password must both be provided or both omitted"
+ )));
+ }
+
+ Ok(Self::new(EmailConfig {
+ host,
+ port,
+ username,
+ password,
+ default_from,
+ tls_mode,
+ }))
+ }
+
+ #[cfg(test)]
+ fn with_sender_for_test(config: EmailConfig, sender: EmailSender) -> Self {
+ Self { config, sender }
+ }
+
+ fn prepare_send_request(&self, args: &[Value], kwargs: &[(String, Value)]) -> RResult<PreparedEmail> {
+ let sig = "email.send(to, subject, text=None, html=None, cc=None, bcc=None, reply_to=None, from=None, headers=None, attachments=None)";
+
+ let to_raw = kwarg_or_arg(kwargs, args, "to", 0)
+ .ok_or_else(|| RelayError::Type(format!("{sig} missing to")))?;
+ let to = parse_mailbox_list(to_raw, sig, "to")?;
+ if to.is_empty() {
+ return Err(RelayError::Type(format!("{sig} to cannot be empty")));
+ }
+
+ let subject = kwarg_or_arg(kwargs, args, "subject", 1)
+ .ok_or_else(|| RelayError::Type(format!("{sig} missing subject")))?;
+ let subject = expect_str_value(subject, sig, "subject")?;
+
+ let text = parse_optional_str_value(kwarg_or_arg(kwargs, args, "text", 2), sig, "text")?;
+ let html = parse_optional_str_value(kwarg_or_arg(kwargs, args, "html", 3), sig, "html")?;
+ if text.is_none() && html.is_none() {
+ return Err(RelayError::Type(format!(
+ "{sig} requires at least one body part: text or html"
+ )));
+ }
+
+ let cc = parse_mailbox_list(
+ kwarg_or_arg(kwargs, args, "cc", 4).unwrap_or(Value::None),
+ sig,
+ "cc",
+ )?;
+ let bcc = parse_mailbox_list(
+ kwarg_or_arg(kwargs, args, "bcc", 5).unwrap_or(Value::None),
+ sig,
+ "bcc",
+ )?;
+ let reply_to = parse_optional_mailbox(kwarg_or_arg(kwargs, args, "reply_to", 6), sig, "reply_to")?;
+
+ let from_override = parse_optional_str_value(kwarg_or_arg(kwargs, args, "from", 7), sig, "from")?;
+ let from = from_override
+ .or_else(|| self.config.default_from.clone())
+ .ok_or_else(|| {
+ RelayError::Type(format!(
+ "{sig} requires from in Email(...) or send(...)"
+ ))
+ })?;
+ let from = parse_mailbox(from, sig, "from")?;
+
+ let headers = kwarg_or_arg(kwargs, args, "headers", 8)
+ .map(|v| parse_headers_value(v, sig))
+ .transpose()?
+ .unwrap_or_default();
+ let attachments = parse_email_attachments(kwarg_or_arg(kwargs, args, "attachments", 9), sig)?;
+
+ let mut builder = MailMessage::builder().from(from).subject(subject);
+ for mb in &to {
+ builder = builder.to(mb.clone());
+ }
+ for mb in &cc {
+ builder = builder.cc(mb.clone());
+ }
+ for mb in &bcc {
+ builder = builder.bcc(mb.clone());
+ }
+ if let Some(reply_to) = reply_to {
+ builder = builder.reply_to(reply_to);
+ }
+
+ for (name, value) in headers {
+ let header_name = mail_header::HeaderName::new_from_ascii(name.clone())
+ .map_err(|e| RelayError::Type(format!(
+ "{sig} invalid header name '{name}': {e}"
+ )))?;
+ builder = builder.raw_header(mail_header::HeaderValue::new(header_name, value));
+ }
+
+ let message = if attachments.is_empty() {
+ match (text, html) {
+ (Some(text), Some(html)) => builder
+ .multipart(
+ MultiPart::alternative()
+ .singlepart(SinglePart::plain(text))
+ .singlepart(SinglePart::html(html)),
+ )
+ .map_err(|e| RelayError::Runtime(format!("email message build failed: {e}")))?,
+ (Some(text), None) => builder
+ .singlepart(SinglePart::plain(text))
+ .map_err(|e| RelayError::Runtime(format!("email message build failed: {e}")))?,
+ (None, Some(html)) => builder
+ .singlepart(SinglePart::html(html))
+ .map_err(|e| RelayError::Runtime(format!("email message build failed: {e}")))?,
+ (None, None) => {
+ return Err(RelayError::Type(format!(
+ "{sig} requires at least one body part: text or html"
+ )))
+ }
+ }
+ } else {
+ let mut mixed = MultiPart::mixed().build();
+ mixed = match (text, html) {
+ (Some(text), Some(html)) => mixed.multipart(
+ MultiPart::alternative()
+ .singlepart(SinglePart::plain(text))
+ .singlepart(SinglePart::html(html)),
+ ),
+ (Some(text), None) => mixed.singlepart(SinglePart::plain(text)),
+ (None, Some(html)) => mixed.singlepart(SinglePart::html(html)),
+ (None, None) => {
+ return Err(RelayError::Type(format!(
+ "{sig} requires at least one body part: text or html"
+ )))
+ }
+ };
+
+ for attachment in attachments {
+ let content_type = mail_header::ContentType::parse(&attachment.content_type)
+ .map_err(|e| {
+ RelayError::Type(format!(
+ "{sig} invalid attachment content_type '{}': {e}",
+ attachment.content_type
+ ))
+ })?;
+ mixed = mixed.singlepart(
+ MailAttachment::new(attachment.filename).body(attachment.bytes, content_type),
+ );
+ }
+
+ builder
+ .multipart(mixed)
+ .map_err(|e| RelayError::Runtime(format!("email message build failed: {e}")))?
+ };
+
+ let envelope_to = to
+ .iter()
+ .chain(cc.iter())
+ .chain(bcc.iter())
+ .map(|mb| mb.email.to_string())
+ .collect::<Vec<_>>();
+
+ Ok(PreparedEmail {
+ message,
+ envelope_to,
+ message_id: None,
+ })
+ }
+
+ fn get_member(&self, name: &str) -> Value {
+ match name {
+ "send" => {
+ let email = self.clone();
+ Value::Builtin(Arc::new(move |args, kwargs| {
+ let email = email.clone();
+ Box::pin(async move {
+ let prepared = email.prepare_send_request(&args, &kwargs)?;
+ let PreparedEmail {
+ message,
+ envelope_to,
+ message_id,
+ } = prepared;
+ let sender = email.sender.clone();
+ let host = email.config.host.clone();
+ let port = email.config.port;
+
+ let d = Deferred::new(Box::pin(async move {
+ let metadata = sender(message).await?;
+ let mut out = IndexMap::new();
+ out.insert("ok".into(), Value::Bool(true));
+ out.insert("transport".into(), Value::Str("smtp".into()));
+ out.insert("host".into(), Value::Str(host));
+ out.insert("port".into(), Value::Int(port as i64));
+ out.insert(
+ "message_id".into(),
+ metadata
+ .message_id
+ .or(message_id)
+ .map(Value::Str)
+ .unwrap_or(Value::None),
+ );
+ out.insert(
+ "envelope_to".into(),
+ Value::List(envelope_to.into_iter().map(Value::Str).collect()),
+ );
+ out.insert(
+ "smtp_code".into(),
+ metadata
+ .smtp_code
+ .map(Value::Int)
+ .unwrap_or(Value::None),
+ );
+ out.insert(
+ "smtp_message".into(),
+ metadata
+ .smtp_message
+ .map(Value::Str)
+ .unwrap_or(Value::None),
+ );
+ Ok(Value::Dict(out))
+ }));
+ Ok(Value::Deferred(Arc::new(d)))
+ })
+ }))
+ }
+ "render" => {
+ Value::Builtin(Arc::new(move |args, kwargs| {
+ Box::pin(async move {
+ let sig = "email.render(template, data=None)";
+ let template = kwarg_or_arg(&kwargs, &args, "template", 0)
+ .ok_or_else(|| RelayError::Type(format!("{sig} missing template")))?;
+ let template = expect_str_value(template, sig, "template")?;
+ let data = parse_template_locals(kwarg_or_arg(&kwargs, &args, "data", 1), sig)?;
+ let rendered = render_template(&template, &data)?;
+ Ok(Value::Str(rendered))
+ })
+ }))
+ }
+ "render_file" => {
+ Value::Builtin(Arc::new(move |args, kwargs| {
+ Box::pin(async move {
+ let sig = "email.render_file(path, data=None)";
+ let path = kwarg_or_arg(&kwargs, &args, "path", 0)
+ .ok_or_else(|| RelayError::Type(format!("{sig} missing path")))?;
+ let path = expect_str_value(path, sig, "path")?;
+ let data = parse_template_locals(kwarg_or_arg(&kwargs, &args, "data", 1), sig)?;
+
+ let d = Deferred::new(Box::pin(async move {
+ let template = tokio::fs::read_to_string(path)
+ .await
+ .map_err(|e| RelayError::Runtime(e.to_string()))?;
+ let rendered = render_template(&template, &data)?;
+ Ok(Value::Str(rendered))
+ }));
+ Ok(Value::Deferred(Arc::new(d)))
+ })
+ }))
+ }
+ _ => Value::None,
+ }
+ }
+}
+
+#[derive(Clone)]
+enum AuthStoreBackend {
+ Memory(Arc<tokio::sync::Mutex<HashMap<String, String>>>),
+ Callback {
+ load_fn: String,
+ save_fn: String,
+ evaluator: Arc<Evaluator>,
+ },
+}
+
+#[derive(Clone)]
+struct AuthStoreHandle {
+ backend: AuthStoreBackend,
+}
+
+impl AuthStoreHandle {
+ fn memory() -> Self {
+ Self {
+ backend: AuthStoreBackend::Memory(Arc::new(tokio::sync::Mutex::new(HashMap::new()))),
+ }
+ }
+
+ fn callback(evaluator: Arc<Evaluator>, load_fn: String, save_fn: String) -> Self {
+ Self {
+ backend: AuthStoreBackend::Callback {
+ load_fn,
+ save_fn,
+ evaluator,
+ },
+ }
+ }
+
+ async fn load_hash(&self, username: String) -> RResult<Option<String>> {
+ match &self.backend {
+ AuthStoreBackend::Memory(store) => Ok(store.lock().await.get(&username).cloned()),
+ AuthStoreBackend::Callback {
+ load_fn,
+ evaluator,
+ ..
+ } => {
+ let out = evaluator
+ .call_named_function(load_fn, vec![Value::Str(username)])
+ .await?;
+ match out {
+ Value::None => Ok(None),
+ Value::Str(s) => Ok(Some(s)),
+ other => Err(RelayError::Type(format!(
+ "auth load callback must return str/None, got {}",
+ value_type_name(&other)
+ ))),
+ }
+ }
+ }
+ }
+
+ async fn save_hash(&self, username: String, hash: String) -> RResult<()> {
+ match &self.backend {
+ AuthStoreBackend::Memory(store) => {
+ store.lock().await.insert(username, hash);
+ Ok(())
+ }
+ AuthStoreBackend::Callback {
+ save_fn,
+ evaluator,
+ ..
+ } => {
+ let _ = evaluator
+ .call_named_function(save_fn, vec![Value::Str(username), Value::Str(hash)])
+ .await?;
+ Ok(())
+ }
+ }
+ }
+
+ fn get_member(&self, name: &str) -> Value {
+ match name {
+ "set_hash" => {
+ let store = self.clone();
+ Value::Builtin(Arc::new(move |args, _| {
+ let store = store.clone();
+ Box::pin(async move {
+ let username = expect_str(&args, 0, "auth_store.set_hash(username, hash)")?;
+ let hash = expect_str(&args, 1, "auth_store.set_hash(username, hash)")?;
+ store.save_hash(username, hash).await?;
+ Ok(Value::None)
+ })
+ }))
+ }
+ "get_hash" => {
+ let store = self.clone();
+ Value::Builtin(Arc::new(move |args, _| {
+ let store = store.clone();
+ Box::pin(async move {
+ let username = expect_str(&args, 0, "auth_store.get_hash(username)")?;
match store.load_hash(username).await? {
Some(hash) => Ok(Value::Str(hash)),
None => Ok(Value::None),
@@ -5289,6 +6135,17 @@ impl Object {
}))
})
})),
+ "render_template" => Value::Builtin(Arc::new(move |args, kwargs| {
+ Box::pin(async move {
+ let sig = "app.render_template(template, data=None)";
+ let template = kwarg_or_arg(&kwargs, &args, "template", 0)
+ .ok_or_else(|| RelayError::Type(format!("{sig} missing template")))?;
+ let template = expect_str_value(template, sig, "template")?;
+ let data = parse_template_locals(kwarg_or_arg(&kwargs, &args, "data", 1), sig)?;
+ let rendered = render_template(&template, &data)?;
+ Ok(Value::Str(rendered))
+ })
+ })),
"use" => {
let a = a.clone();
Value::Builtin(Arc::new(move |args, _| {
@@ -5380,6 +6237,60 @@ impl Object {
})
}))
}
+ "uploads" => {
+ let a = a.clone();
+ Value::Builtin(Arc::new(move |args, kwargs| {
+ let a = a.clone();
+ Box::pin(async move {
+ let sig = "app.uploads(max_body_bytes=None, max_file_bytes=None, allowed_mime_types=None)";
+ let mut cfg = a.upload_config();
+
+ if let Some(v) = args.get(0) {
+ if !matches!(v, Value::None) {
+ cfg.max_body_bytes =
+ parse_usize_value(v.clone(), sig, "max_body_bytes")?;
+ }
+ }
+ if let Some(v) = args.get(1) {
+ if !matches!(v, Value::None) {
+ cfg.max_file_bytes =
+ parse_usize_value(v.clone(), sig, "max_file_bytes")?;
+ }
+ }
+ if let Some(v) = args.get(2) {
+ cfg.allowed_mime_types = parse_optional_mime_allowlist(
+ Some(v.clone()),
+ sig,
+ "allowed_mime_types",
+ )?;
+ }
+
+ for (k, v) in kwargs {
+ match k.as_str() {
+ "max_body_bytes" => {
+ cfg.max_body_bytes =
+ parse_usize_value(v, sig, "max_body_bytes")?
+ }
+ "max_file_bytes" => {
+ cfg.max_file_bytes =
+ parse_usize_value(v, sig, "max_file_bytes")?
+ }
+ "allowed_mime_types" | "allowed_mimes" => {
+ cfg.allowed_mime_types = parse_optional_mime_allowlist(
+ Some(v),
+ sig,
+ "allowed_mime_types",
+ )?
+ }
+ _ => {}
+ }
+ }
+
+ a.set_upload_config(cfg);
+ Ok(Value::None)
+ })
+ }))
+ }
"session_backend" => {
let a = a.clone();
Value::Builtin(Arc::new(move |args, _| {
@@ -5522,6 +6433,7 @@ impl Object {
},
Object::Http(h) => h.get_member(name),
Object::HttpResponse(r) => r.get_member(name),
+ Object::Email(e) => e.get_member(name),
Object::AuthStore(a) => a.get_member(name),
Object::MongoClient(m) => m.get_member(name),
Object::MongoDatabase(d) => d.get_member(name),
@@ -5583,17 +6495,14 @@ impl Evaluator {
let query_map = query.clone();
let mut json_body = json.clone();
- let form_body = form.clone();
let headers_map = headers.clone();
let mut query_values: IndexMap<String, Value> = query_map
.iter()
.map(|(k, v)| (k.clone(), Value::Str(v.clone())))
.collect();
- let mut form_values: IndexMap<String, Value> = form
- .iter()
- .map(|(k, v)| (k.clone(), Value::Str(v.clone())))
- .collect();
+ let mut form_values: IndexMap<String, Value> =
+ form.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
let mut json_values: Option<IndexMap<String, Value>> = json_body.as_ref().and_then(|j| {
if let J::Object(obj) = j {
Some(
@@ -6102,22 +7011,200 @@ mod tests {
);
}
- #[test]
- fn parses_urlencoded_form_request_body() {
+ #[tokio::test]
+ async fn parses_urlencoded_form_request_body() {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/x-www-form-urlencoded"),
);
- let (json, form) = parse_request_body(&headers, b"content=hello+world&tag=notes");
+ let upload_cfg = UploadConfig::default();
+ let (json, form) = parse_request_body(
+ &headers,
+ Bytes::from_static(b"content=hello+world&tag=notes"),
+ &upload_cfg,
+ )
+ .await
+ .expect("urlencoded parse should succeed");
assert!(json.is_none());
- assert_eq!(
- form.get("content"),
- Some(&"hello world".to_string()),
+ assert!(
+ matches!(form.get("content"), Some(Value::Str(s)) if s == "hello world"),
"content should be URL-decoded"
);
- assert_eq!(form.get("tag"), Some(&"notes".to_string()));
+ assert!(matches!(form.get("tag"), Some(Value::Str(s)) if s == "notes"));
+ }
+
+ #[tokio::test]
+ async fn parses_multipart_form_request_body_with_file_uploads() {
+ let boundary = "relay-boundary";
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ axum::http::header::CONTENT_TYPE,
+ axum::http::HeaderValue::from_str(&format!(
+ "multipart/form-data; boundary={boundary}"
+ ))
+ .expect("header should be valid"),
+ );
+
+ let body = format!(
+ "--{boundary}\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nUpload Test\r\n\
+--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\nContent-Type: text/plain\r\n\r\nhello world\r\n\
+--{boundary}--\r\n"
+ );
+
+ let upload_cfg = UploadConfig::default();
+ let (json, form) = parse_request_body(&headers, Bytes::from(body), &upload_cfg)
+ .await
+ .expect("multipart parse should succeed");
+ assert!(json.is_none());
+ assert!(matches!(
+ form.get("title"),
+ Some(Value::Str(s)) if s == "Upload Test"
+ ));
+
+ let file = form.get("file").expect("file field should exist");
+ match file {
+ Value::Dict(meta) => {
+ assert!(matches!(
+ meta.get("filename"),
+ Some(Value::Str(s)) if s == "hello.txt"
+ ));
+ assert!(matches!(
+ meta.get("content_type"),
+ Some(Value::Str(s)) if s == "text/plain"
+ ));
+ assert!(matches!(meta.get("size"), Some(Value::Int(11))));
+ assert!(matches!(
+ meta.get("bytes"),
+ Some(Value::Bytes(bytes)) if bytes == b"hello world"
+ ));
+ }
+ other => panic!("file field should be dict, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn request_body_limit_rejects_oversized_payloads() {
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ axum::http::header::CONTENT_TYPE,
+ axum::http::HeaderValue::from_static("application/json"),
+ );
+ let upload_cfg = UploadConfig {
+ max_body_bytes: 4,
+ ..UploadConfig::default()
+ };
+
+ let err = parse_request_body(&headers, Bytes::from_static(b"{\"x\":1}"), &upload_cfg)
+ .await;
+ let err = match err {
+ Ok(_) => panic!("body larger than limit should fail"),
+ Err(err) => err,
+ };
+ assert!(matches!(
+ err,
+ RelayError::Type(msg) if msg.contains("max_body_bytes")
+ ));
+ }
+
+ #[tokio::test]
+ async fn multipart_file_limit_rejects_oversized_file() {
+ let boundary = "relay-boundary-limit";
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ axum::http::header::CONTENT_TYPE,
+ axum::http::HeaderValue::from_str(&format!(
+ "multipart/form-data; boundary={boundary}"
+ ))
+ .expect("header should be valid"),
+ );
+ let body = format!(
+ "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"big.txt\"\r\nContent-Type: text/plain\r\n\r\nhello world\r\n--{boundary}--\r\n"
+ );
+ let upload_cfg = UploadConfig {
+ max_file_bytes: 5,
+ ..UploadConfig::default()
+ };
+
+ let err = parse_request_body(&headers, Bytes::from(body), &upload_cfg)
+ .await;
+ let err = match err {
+ Ok(_) => panic!("file larger than max_file_bytes should fail"),
+ Err(err) => err,
+ };
+ assert!(matches!(
+ err,
+ RelayError::Type(msg) if msg.contains("max_file_bytes")
+ ));
+ }
+
+ #[tokio::test]
+ async fn multipart_mime_allowlist_rejects_disallowed_file_type() {
+ let boundary = "relay-boundary-mime";
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ axum::http::header::CONTENT_TYPE,
+ axum::http::HeaderValue::from_str(&format!(
+ "multipart/form-data; boundary={boundary}"
+ ))
+ .expect("header should be valid"),
+ );
+ let body = format!(
+ "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\nContent-Type: text/plain\r\n\r\nhello world\r\n--{boundary}--\r\n"
+ );
+ let upload_cfg = UploadConfig {
+ allowed_mime_types: Some(HashSet::from(["image/png".to_string()])),
+ ..UploadConfig::default()
+ };
+
+ let err = parse_request_body(&headers, Bytes::from(body), &upload_cfg)
+ .await;
+ let err = match err {
+ Ok(_) => panic!("disallowed mime should fail"),
+ Err(err) => err,
+ };
+ assert!(matches!(
+ err,
+ RelayError::Type(msg) if msg.contains("not allowed")
+ ));
+ }
+
+ #[tokio::test]
+ async fn webapp_uploads_method_updates_upload_config() {
+ let src = r#"app = WebApp()
+app.uploads(
+ max_body_bytes=2048,
+ max_file_bytes=512,
+ allowed_mime_types=["image/png", "application/pdf"]
+)
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let app = {
+ let env_lock = env.lock().await;
+ match env_lock.get("app").expect("app should exist") {
+ Value::Obj(Object::WebApp(app)) => app,
+ other => panic!("expected app to be WebApp, got {}", other.repr()),
+ }
+ };
+
+ let cfg = app.upload_config();
+ assert_eq!(cfg.max_body_bytes, 2048);
+ assert_eq!(cfg.max_file_bytes, 512);
+ let allowed = cfg.allowed_mime_types.expect("allowlist should be set");
+ assert!(allowed.contains("image/png"));
+ assert!(allowed.contains("application/pdf"));
}
#[tokio::test]
@@ -6193,7 +7280,7 @@ greet("ada")
let mut query = HashMap::new();
query.insert("content".to_string(), "from-query".to_string());
let mut form = HashMap::new();
- form.insert("content".to_string(), "from-form".to_string());
+ form.insert("content".to_string(), Value::Str("from-form".to_string()));
let out = evaluator
.call_web_handler(
@@ -6390,7 +7477,7 @@ fn from_query()
}
let mut form = HashMap::new();
- form.insert("name".to_string(), "from-form-helper".to_string());
+ form.insert("name".to_string(), Value::Str("from-form-helper".to_string()));
let out_form = evaluator
.call_web_handler(
WebAppHandle::new(),
@@ -6454,6 +7541,95 @@ fn from_query()
}
}
+ #[tokio::test]
+ async fn web_handler_can_access_multipart_uploaded_file_metadata() {
+ let src = r#"fn upload(file)
+ return file["filename"] + ":" + str(file["size"])
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let boundary = "relay-boundary-upload";
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ axum::http::header::CONTENT_TYPE,
+ axum::http::HeaderValue::from_str(&format!(
+ "multipart/form-data; boundary={boundary}"
+ ))
+ .expect("header should be valid"),
+ );
+ let body = format!(
+ "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\nContent-Type: text/plain\r\n\r\nhello world\r\n--{boundary}--\r\n"
+ );
+ let upload_cfg = UploadConfig::default();
+ let (_json, form) = parse_request_body(&headers, Bytes::from(body), &upload_cfg)
+ .await
+ .expect("multipart parse should succeed");
+
+ let out = evaluator
+ .call_web_handler(
+ WebAppHandle::new(),
+ "upload",
+ RequestParts {
+ method: "POST".to_string(),
+ path: "/upload".to_string(),
+ request_id: "rid-upload".to_string(),
+ route_validation: RouteValidation::default(),
+ path_params: HashMap::new(),
+ query: HashMap::new(),
+ json: None,
+ form,
+ headers: HashMap::new(),
+ cookies: HashMap::new(),
+ session_id: None,
+ websocket: None,
+ },
+ )
+ .await
+ .expect("handler should run");
+
+ match out {
+ Value::Response(resp) => {
+ let text = String::from_utf8(resp.body).expect("response body should be utf-8");
+ assert_eq!(text, "hello.txt:11");
+ }
+ other => panic!("expected response, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn webapp_render_template_method_renders_explicit_template() {
+ let src = r#"app = WebApp()
+rendered = app.render_template("Hello {{ name }}", {"name": "Ada"})
+"#;
+ let program = parse_src(src).expect("program should parse");
+ let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
+ let evaluator = Arc::new(Evaluator::new(env.clone()));
+ {
+ let mut env_lock = env.lock().await;
+ install_stdlib(&mut env_lock, evaluator.clone()).expect("stdlib install should work");
+ }
+ evaluator
+ .eval_program(&program)
+ .await
+ .expect("program should evaluate");
+
+ let env_lock = env.lock().await;
+ assert!(matches!(
+ env_lock.get("rendered").expect("rendered should exist"),
+ Value::Str(s) if s == "Hello Ada"
+ ));
+ }
+
#[tokio::test]
async fn http_error_builtin_returns_structured_json_and_request_id() {
let src = r#"fn fail()
@@ -7243,6 +8419,282 @@ fn get_user(user_id)
}
}
+ #[test]
+ fn email_constructor_rejects_invalid_tls_mode() {
+ let err = match EmailHandle::from_constructor_args(
+ &[Value::Str("smtp.example.com".into())],
+ &[("tls".into(), Value::Str("bad-mode".into()))],
+ ) {
+ Ok(_) => panic!("invalid tls mode should fail"),
+ Err(err) => err,
+ };
+
+ assert!(matches!(
+ err,
+ RelayError::Type(msg) if msg.contains("expects one of")
+ ));
+ }
+
+ #[test]
+ fn email_client_exposes_send_and_template_methods() {
+ let email = EmailHandle::new(EmailConfig {
+ host: "smtp.example.com".into(),
+ port: 587,
+ username: None,
+ password: None,
+ default_from: Some("Relay <[email protected]>".into()),
+ tls_mode: EmailTlsMode::StartTls,
+ });
+
+ assert!(!matches!(email.get_member("send"), Value::None));
+ assert!(!matches!(email.get_member("render"), Value::None));
+ assert!(!matches!(email.get_member("render_file"), Value::None));
+ }
+
+ #[tokio::test]
+ async fn email_send_returns_deferred_and_metadata_with_mock_sender() {
+ let email = EmailHandle::with_sender_for_test(
+ EmailConfig {
+ host: "smtp.example.com".into(),
+ port: 587,
+ username: None,
+ password: None,
+ default_from: Some("Relay <[email protected]>".into()),
+ tls_mode: EmailTlsMode::StartTls,
+ },
+ Arc::new(|_msg| {
+ Box::pin(async move {
+ Ok(EmailSendMetadata {
+ message_id: Some("<[email protected]>".into()),
+ smtp_code: Some(250),
+ smtp_message: Some("2.0.0 queued".into()),
+ })
+ })
+ }),
+ );
+
+ let send_fn = match email.get_member("send") {
+ Value::Builtin(f) => f,
+ other => panic!("send should be builtin, got {}", other.repr()),
+ };
+
+ let out = send_fn(
+ vec![
+ Value::Str("[email protected]".into()),
+ Value::Str("Welcome".into()),
+ Value::Str("Hello Ada".into()),
+ ],
+ vec![],
+ )
+ .await
+ .expect("send call should succeed");
+
+ let deferred = match out {
+ Value::Deferred(d) => d,
+ other => panic!("send should return Deferred, got {}", other.repr()),
+ };
+ let resolved = deferred.resolve().await.expect("deferred should resolve");
+
+ match resolved {
+ Value::Dict(map) => {
+ assert!(matches!(map.get("ok"), Some(Value::Bool(true))));
+ assert!(matches!(
+ map.get("transport"),
+ Some(Value::Str(s)) if s == "smtp"
+ ));
+ assert!(matches!(
+ map.get("host"),
+ Some(Value::Str(s)) if s == "smtp.example.com"
+ ));
+ assert!(matches!(map.get("port"), Some(Value::Int(587))));
+ assert!(matches!(
+ map.get("message_id"),
+ Some(Value::Str(s)) if s == "<[email protected]>"
+ ));
+ assert!(matches!(map.get("smtp_code"), Some(Value::Int(250))));
+ assert!(matches!(
+ map.get("smtp_message"),
+ Some(Value::Str(s)) if s == "2.0.0 queued"
+ ));
+ }
+ other => panic!("resolved value should be dict, got {}", other.repr()),
+ }
+ }
+
+ #[tokio::test]
+ async fn email_send_supports_multipart_attachments() {
+ let captured = Arc::new(tokio::sync::Mutex::new(None::<String>));
+ let captured_for_sender = captured.clone();
+
+ let email = EmailHandle::with_sender_for_test(
+ EmailConfig {
+ host: "smtp.example.com".into(),
+ port: 587,
+ username: None,
+ password: None,
+ default_from: Some("Relay <[email protected]>".into()),
+ tls_mode: EmailTlsMode::StartTls,
+ },
+ Arc::new(move |msg| {
+ let captured = captured_for_sender.clone();
+ Box::pin(async move {
+ let formatted = String::from_utf8_lossy(&msg.formatted()).to_string();
+ *captured.lock().await = Some(formatted);
+ Ok(EmailSendMetadata::default())
+ })
+ }),
+ );
+
+ let mut attachment = IndexMap::new();
+ attachment.insert("filename".into(), Value::Str("hello.txt".into()));
+ attachment.insert("content_type".into(), Value::Str("text/plain".into()));
+ attachment.insert("bytes".into(), Value::Bytes(b"hello world".to_vec()));
+
+ let send_fn = match email.get_member("send") {
+ Value::Builtin(f) => f,
+ other => panic!("send should be builtin, got {}", other.repr()),
+ };
+
+ let out = send_fn(
+ vec![
+ Value::Str("[email protected]".into()),
+ Value::Str("Attachment test".into()),
+ Value::Str("See attachment".into()),
+ ],
+ vec![("attachments".into(), Value::List(vec![Value::Dict(attachment)]))],
+ )
+ .await
+ .expect("send should succeed");
+
+ let deferred = match out {
+ Value::Deferred(d) => d,
+ other => panic!("send should return Deferred, got {}", other.repr()),
+ };
+ let _ = deferred.resolve().await.expect("send deferred should resolve");
+
+ let formatted = captured
+ .lock()
+ .await
+ .clone()
+ .expect("formatted message should be captured");
+ assert!(
+ formatted.contains("multipart/mixed"),
+ "email with attachments should use multipart/mixed"
+ );
+ assert!(
+ formatted.contains("filename=\"hello.txt\""),
+ "attachment filename should be present in MIME headers"
+ );
+ }
+
+ #[tokio::test]
+ async fn email_send_requires_body_part() {
+ let email = EmailHandle::with_sender_for_test(
+ EmailConfig {
+ host: "smtp.example.com".into(),
+ port: 587,
+ username: None,
+ password: None,
+ default_from: Some("Relay <[email protected]>".into()),
+ tls_mode: EmailTlsMode::StartTls,
+ },
+ Arc::new(|_msg| Box::pin(async move { Ok(EmailSendMetadata::default()) })),
+ );
+
+ let send_fn = match email.get_member("send") {
+ Value::Builtin(f) => f,
+ other => panic!("send should be builtin, got {}", other.repr()),
+ };
+
+ let err = send_fn(
+ vec![
+ Value::Str("[email protected]".into()),
+ Value::Str("No body".into()),
+ ],
+ vec![],
+ )
+ .await;
+ let err = match err {
+ Ok(_) => panic!("missing body should fail"),
+ Err(err) => err,
+ };
+
+ assert!(matches!(
+ err,
+ RelayError::Type(msg) if msg.contains("requires at least one body part")
+ ));
+ }
+
+ #[tokio::test]
+ async fn email_render_and_render_file_support_template_data() {
+ let email = EmailHandle::new(EmailConfig {
+ host: "smtp.example.com".into(),
+ port: 587,
+ username: None,
+ password: None,
+ default_from: Some("Relay <[email protected]>".into()),
+ tls_mode: EmailTlsMode::StartTls,
+ });
+
+ let mut data = IndexMap::new();
+ data.insert("name".into(), Value::Str("Ada".into()));
+
+ let render_fn = match email.get_member("render") {
+ Value::Builtin(f) => f,
+ other => panic!("render should be builtin, got {}", other.repr()),
+ };
+
+ let rendered_inline = render_fn(
+ vec![
+ Value::Str("Hello {{ name }}".into()),
+ Value::Dict(data.clone()),
+ ],
+ vec![],
+ )
+ .await
+ .expect("render should succeed");
+ assert!(matches!(
+ rendered_inline,
+ Value::Str(s) if s == "Hello Ada"
+ ));
+
+ let file_name = format!(
+ "relay-email-template-{}.txt",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("system time should be after epoch")
+ .as_nanos()
+ );
+ let template_path = std::env::temp_dir().join(file_name);
+ tokio::fs::write(&template_path, "File says hi {{ name }}")
+ .await
+ .expect("template write should work");
+
+ let render_file_fn = match email.get_member("render_file") {
+ Value::Builtin(f) => f,
+ other => panic!("render_file should be builtin, got {}", other.repr()),
+ };
+ let rendered_file = render_file_fn(
+ vec![
+ Value::Str(template_path.to_string_lossy().to_string()),
+ Value::Dict(data),
+ ],
+ vec![],
+ )
+ .await
+ .expect("render_file call should succeed");
+ let rendered_file = match rendered_file {
+ Value::Deferred(d) => d.resolve().await.expect("render_file deferred should resolve"),
+ other => panic!("render_file should return Deferred, got {}", other.repr()),
+ };
+ assert!(matches!(
+ rendered_file,
+ Value::Str(s) if s == "File says hi Ada"
+ ));
+
+ let _ = tokio::fs::remove_file(template_path).await;
+ }
+
#[tokio::test]
async fn supports_index_assignment_and_missing_dict_keys() {
let src = r#"numbers = [1, 2, 3]