Compare commits

...
7 Commits
Author SHA1 Message Date
Bendik Aagaard Lynghaug 77867090ed Release 0.5.5
Test / test (push) Successful in 55s
Publish release / publish (push) Successful in 3m29s
2026-09-30 07:51:06 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 c24e117f67 Changelog: 0.5.5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 07:51:04 +02:00
bl 95928f7265 Merge pull request 'One record answers another, from a list' (#4) from answer-a-record into main
Test / test (push) Successful in 57s
2026-09-30 05:51:04 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 f0e50477c6 aggregates.yaml accepts a mail to a field of the record this one belongs to
Test / test (pull_request) Successful in 55s
The parser refused {notice.email} before the mailer could resolve it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 06:55:27 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 fc998c8592 One record answers another, from a list
Test / test (pull_request) Successful in 56s
Three small things that together make a board work: a link on every
listed record to a page about it (ResourceSpec.link, {id} and {field}
in the address); a state mail whose `to` is a field of the record this
one belongs to ({notice.email}), with that record's fields in the
text, and a lint that the form carries the record field it reaches
through; and value: "{user.email}" filled from the session and hidden,
so someone the site knows is not asked who they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-30 06:47:36 +02:00
bl 470338b969 Merge pull request 'The way down the tree is the link at the foot, not an arrow that follows' (#3) from foot-only into main
Test / test (push) Successful in 55s
2026-09-29 19:24:01 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 86ec51012e The way down the tree is the link at the foot, not an arrow that follows
Test / test (pull_request) Successful in 56s
0.5.3 put the same link in two places: in words at the foot of the
page, and as an arrow fixed at the left edge half-way up the screen.
The arrow stayed with the reader the whole way down a page to offer
what the foot offers where they end up anyway. It is removed; the foot
link and where it leads are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
2026-09-29 21:22:54 +02:00
11 changed files with 254 additions and 75 deletions
+5
View File
@@ -4,6 +4,11 @@ One line per change, grouped by the release that shipped it. Newest
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
content repos pin the tag they run (see the end of this file).
## 0.5.5 (2026-09-30)
- One record answers another, from a list. `link` on a resource puts a link on every listed record to a page about it (`{id}`, `{field}`), a state mail's `to` may be a field of the record this one belongs to (`{notice.email}`, and `{notice.field}` in the text), and `value: "{user.email}"` fills a field from the session and hides it, so a signed-in person is not asked who they are. Together they make a board: a notice is listed with "answer this", the answer page's form carries the notice's id, and the mail goes to whoever posted it. The lint requires the record field the mail reaches through.
- The way down the tree is the line at the foot of the page and nothing else. The arrow fixed at the left edge, which 0.5.3 added beside it, is gone: it followed the reader down every page to offer something the foot already does.
## 0.5.4 (2026-09-29)
- Accounts the content mandates, made at start (`src/seed.rs`). Every person a page names as `responsible` gets a Kanidm account if they have none, and a mail in the site's language saying they are listed as responsible for asking that question, with the link to sign in; `RESPONSIBLE_GROUP` puts them all in one group (people who already had an account are added without a mail). `SEED_ADMIN_EMAIL` (and `SEED_ADMIN_NAME`, `SEED_GROUP`) invites the first person into the site's most privileged group - the group whose desk may invite into the most groups - so a site with its own Kanidm has someone to invite everyone else. Both happen once per address and group (`portal_seed`), side by side, and each tries again a minute apart, twenty times, while Kanidm comes up. The responsible are gone over again whenever the content changes (`portal.content.reloaded`, published once new content is in place), so a new page's person is set up without a restart - and someone the content has stopped naming leaves `RESPONSIBLE_GROUP` in the same pass, if portal is who put them there.
Generated
+1 -1
View File
@@ -3357,7 +3357,7 @@ dependencies = [
[[package]]
name = "portal"
version = "0.5.4"
version = "0.5.5"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "portal"
version = "0.5.4"
version = "0.5.5"
edition = "2021"
[lib]
+18
View File
@@ -136,6 +136,10 @@ is a feature wasted: each one says something true about the choice.
value: "{key}" # a preset; a dynamic page's segment fills it
```
`value: "{user.email}"` or `"{user.name}"` is the signed-in person's
own, and a field it fills is carried rather than asked: someone the
site knows is not asked who they are. A visitor gets the field, empty.
`type` is one of:
| type | what it is |
@@ -192,6 +196,19 @@ The other half is a listing that asks for the children:
where: { field: case, equals: "{key}" }
```
And the way there from a list: `link` puts a link on every listed
record to a page about it, with `{id}` for the record's id and
`{field}` for any of its fields. A notice on a public board links to
`/oppslag/{id}`, the dynamic page `/oppslag/[key]` whose form carries
the notice's id back in with `value: "{key}"`:
```yaml
resource:
source: { kind: kv, bucket: oppslag }
public: true
link: { to: "/oppslag/{id}", label: Jeg kan låne ut eller låne denne }
```
## A resource
Live data, declared by content and read by the server. What a resource
@@ -206,6 +223,7 @@ reads is never a client-supplied parameter.
where: { field: case, equals: "{key}" }
jq: ".[] | {name, url: .html_url}"
empty: Nothing decided yet.
link: { to: "/oppslag/{id}", label: Answer this } # a page per record
transitions: # the desk's buttons
- { from: complete, to: on_hearing, label: Send it out }
```
+7
View File
@@ -62,6 +62,13 @@ as text with an HTML alternative. `to: submitter` means the record's
`email` field, and the lint then requires every form filling that
bucket to collect a required `email`.
A record that belongs to another (a `type: record` field, say
`notice`) can write to that record's people: `to: "{notice.email}"`
goes to whoever posted the notice, and `{notice.hva}` in the body is
what they posted. The lint requires every form filling the bucket to
carry that field. This is how an answer to a notice on a board reaches
the neighbour who put it there, without anyone in between.
Portal renders it and publishes on `portal.mail.send`. gdo delivers
it, and a reply comes back on `portal.mail.received` as a note beside
the record. A note never moves a case.
+12 -7
View File
@@ -55,11 +55,15 @@ pub struct AggregateSchema {
/// A mail to send when a record enters a state. `to` is `submitter`
/// (the record's `email` field, which the lint then requires the
/// forms to collect) or one fixed address. Subject and body are
/// templates: `{field}` for any field of the record, plus `{site}`
/// (the instance's public URL), `{chain}` (the record id), `{email}`,
/// `{bucket}` and `{state}`. The body is markdown; the mailer sends it
/// as plain text with an HTML alternative.
/// forms to collect), one fixed address, or a field of the record
/// this one belongs to: `{notice.email}` when the form has a
/// `type: record` field called `notice`. That is how an answer to a
/// notice reaches whoever posted it. Subject and body are templates:
/// `{field}` for any field of the record, `{notice.field}` for one of
/// the record it belongs to, plus `{site}` (the instance's public
/// URL), `{chain}` (the record id), `{email}`, `{bucket}` and
/// `{state}`. The body is markdown; the mailer sends it as plain text
/// with an HTML alternative.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MailSpec {
@@ -218,9 +222,10 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
if mail.subject.trim().is_empty() || mail.body.trim().is_empty() {
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: mail needs a subject and a body");
}
if !mail.to_submitter() && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
let through_a_record = crate::mail::parent_of(&mail.to).is_some();
if !mail.to_submitter() && !through_a_record && !(mail.to.contains('@') && !mail.to.contains(char::is_whitespace)) {
anyhow::bail!(
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter` or one address, not {:?}",
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter`, one address, or a field of the record this one belongs to like `{{notice.email}}`, not {:?}",
mail.to
);
}
+24 -22
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::answers::{Answer, SelfTransitionAnswer, TransitionAnswers, TransitionItem};
use crate::auth::{current_user, User};
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, SiteConfig, Transition};
use crate::content::{is_qualified, render_inline_markdown, Alternative, Question, Responsible, RowLink, SiteConfig, Transition};
use crate::i18n::t;
use crate::resource::{get_requirement_binding, get_requirement_options, get_resource};
@@ -477,7 +477,7 @@ fn QuestionView(
/// Every other question the visitor currently qualifies for, as a
/// small nav - how a concern reaches the people it speaks to without
/// claiming front-page space (an owner also sees the gated desks here).
/// Where the arrow on the left takes a visitor: the nearest page below
/// Where the link at the foot of a page takes a visitor: the nearest page below
/// the current one in the tree that they may open, found by taking the
/// path apart one segment at a time (`/a/b/c`, then `/a/b`, then `/a`),
/// and the front door when none of those is a page. `None` on the front
@@ -523,24 +523,10 @@ fn QuestionNav(current_id: String) -> impl IntoView {
let NavRes(nav) = expect_context();
let location = leptos_router::hooks::use_location();
view! {
// The way down the tree: fixed at the left edge, half-way up the
// screen, small enough never to cover what a visitor is reading.
// It replaces a "back" alternative on every page that had one.
<Suspense fallback=|| ()>
{move || {
let path = location.pathname.get();
nav.get().and_then(|res| res.ok()).and_then(|items| down_the_tree(&path, &items)).map(|(href, name)| {
let label = down_the_tree_label(&lang().get(), &href, &name);
view! {
<a class="down-the-tree" href=href aria-label=label.clone() title=label>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5 8 12l7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
}
})
}}
</Suspense>
// The same way down, at the foot of the page, in words: where a
// reader who has read to the end looks for where to go next.
// The way down the tree, at the foot of the page, in words:
// where a reader who has read to the end looks for where to go
// next. It replaces a "back" alternative on every page that had
// one.
<Suspense fallback=|| ()>
{move || {
let path = location.pathname.get();
@@ -1315,6 +1301,7 @@ fn AlternativeCard(
alternative=alt_name.clone()
feature_name=feature_name.clone()
transitions=spec.transitions.clone()
link=spec.link.clone()
empty=spec.empty.clone()
pending_transitions=pending_transitions
transition_batch=transition_batch
@@ -1767,6 +1754,7 @@ fn ResourceFeature(
alternative: String,
feature_name: String,
transitions: Vec<Transition>,
link: Option<RowLink>,
empty: Option<String>,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
@@ -1801,6 +1789,7 @@ fn ResourceFeature(
{move || {
let feature_name = feature_name.clone();
let transitions = transitions.clone();
let link = link.clone();
let empty = empty.clone().unwrap_or_else(|| t(&lang().get(), "nothing_here_yet").to_string());
data.get()
.map(|res| match res {
@@ -1809,6 +1798,7 @@ fn ResourceFeature(
<ResourceValue
feature_name=feature_name
transitions=transitions
link=link
empty=empty
value=value
pending_transitions=pending_transitions
@@ -1836,6 +1826,7 @@ fn ResourceFeature(
fn ResourceValue(
feature_name: String,
transitions: Vec<Transition>,
link: Option<RowLink>,
empty: String,
value: serde_json::Value,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
@@ -1867,11 +1858,13 @@ fn ResourceValue(
children={
let feature_name = feature_name.clone();
let transitions = transitions.clone();
let link = link.clone();
move |answer: Answer| {
view! {
<AnswerRow
feature_name=feature_name.clone()
transitions=transitions.clone()
link=link.clone()
answer=answer
pending_transitions=pending_transitions
transition_batch=transition_batch
@@ -2130,6 +2123,7 @@ fn SelectField(
fn AnswerRow(
feature_name: String,
transitions: Vec<Transition>,
link: Option<RowLink>,
answer: Answer,
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
transition_batch: ServerAction<TransitionAnswers>,
@@ -2162,6 +2156,7 @@ fn AnswerRow(
let state = answer.state.clone();
let item_id = answer.id.clone();
let key = (feature_name.clone(), item_id.clone());
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
view! {
<article class="answer-row" class:decided=move || decided>
@@ -2209,6 +2204,9 @@ fn AnswerRow(
<span class="answer-state">{state}</span>
<span class="answer-time">{format_ms(submitted_ms)}</span>
</div>
// The page about this record, when the list offers one:
// where a reader answers a notice.
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
// What the person has written back since, oldest first: the
// conversation beside the case, which a desk could not see
// before. A note moves nothing, so there is no button here.
@@ -2352,13 +2350,17 @@ pub async fn get_question(
path: String,
chain: Option<String>,
) -> Result<Option<Page>, ServerFnError> {
use crate::content::{path_matches, resolve_question};
use crate::auth::{User, SESSION_USER_KEY};
use crate::content::{fill_from_user, path_matches, resolve_question};
use crate::server::AppState;
let state = expect_context::<AppState>();
let questions = state.questions.load();
let Some(question) = resolve_question(&questions, &path) else {
let Some(mut question) = resolve_question(&questions, &path) else {
return Ok(None);
};
let session: tower_sessions::Session = leptos_axum::extract().await?;
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
fill_from_user(&mut question, user.as_ref());
let mut chain_gate = None;
if let Some(target) = &question.requires_chain {
// The claimed lineage must verifiably end at the required
+85 -1
View File
@@ -207,6 +207,35 @@ impl Question {
}
}
/// The signed-in person's own details into the fields that ask for
/// them: `value: "{user.email}"` becomes their address and the field
/// hidden, since asking someone who is known to say who they are is
/// the form doubting them. Without a session the placeholder is
/// emptied, and the field asks. Only the whole of a value is filled
/// this way; a value that mixes a user placeholder with other text is
/// left alone.
pub fn fill_from_user(question: &mut Question, user: Option<&crate::auth::User>) {
for alt in &mut question.alternatives {
for feature in &mut alt.features {
for req in &mut feature.requirements {
let Some(value) = req.value.as_deref() else { continue };
let filled = match value {
"{user.email}" => user.and_then(|u| u.email.clone()),
"{user.name}" => user.map(|u| u.display_name.clone()),
_ => continue,
};
match filled.filter(|v| !v.trim().is_empty()) {
Some(v) => {
req.value = Some(v);
req.kind = "hidden".to_string();
}
None => req.value = None,
}
}
}
}
}
/// An alternative's `action` may carry `{field}` / `{field.sub}`
/// placeholders filled from the submitted responses - `/shape/{curve.key}`
/// lands on the dynamic page `/shape/[key]` for the key the visitor's
@@ -439,6 +468,14 @@ pub struct ResourceSpec {
/// didn't declare. `Kv` sources only.
#[serde(default)]
pub transitions: Vec<Transition>,
/// A way from each listed record to a page about it: `to` is the
/// page, with `{id}` for the record's id and `{field}` for any of
/// its fields, and `label` the words on the link. The other half
/// of `type: record`, seen from the list: a notice on a board links
/// to the page where a neighbour answers it, and that page's form
/// carries the notice's id back in.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<RowLink>,
/// A jq filter (evaluated via `jaq`, no shell-out) reshaping the
/// fetched value before it reaches the frontend - e.g.
/// `.[] | {name, url: .html_url}`. `None` returns it as-is.
@@ -542,7 +579,10 @@ pub struct Requirement {
/// A preset value the field starts with. On a dynamic page the
/// URL segment substitutes into `{name}` placeholders here, like
/// resource keys - `value: "{key}"` on a hidden field carries the
/// page's key into the answer.
/// page's key into the answer. `{user.email}` and `{user.name}`
/// are the signed-in person's, and a field they fill is carried
/// rather than asked (see `fill_from_user`); for a visitor they
/// are empty, and the field asks as usual.
#[serde(default)]
pub value: Option<String>,
/// `type: record` only - the bucket the referenced record lives in.
@@ -574,6 +614,30 @@ pub struct Requirement {
/// listing may ask about a record's own contents: one field, one
/// value, compared as strings. Anything more is a query language, and
/// a jq filter is already there for shaping what comes back.
/// A link from every listed record to a page about it (`ResourceSpec.link`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RowLink {
pub to: String,
pub label: String,
}
impl RowLink {
/// The link for one record: `{id}` is the record's id, `{name}`
/// any string field of its responses. A placeholder nothing fills
/// stays as it is, which is visible, rather than a wrong page.
pub fn href(&self, id: &str, responses: &serde_json::Value) -> String {
let mut out = self.to.replace("{id}", id);
if let Some(fields) = responses.as_object() {
for (k, v) in fields {
if let Some(s) = v.as_str() {
out = out.replace(&format!("{{{k}}}"), s);
}
}
}
out
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FieldIs {
pub field: String,
@@ -1790,6 +1854,26 @@ alternatives:
assert_eq!(render_inline_markdown("a\nb"), "a b");
}
#[test]
fn a_signed_in_person_is_not_asked_who_they_are() {
let yaml = "id: /oppslag/[key]\nname: P\nalternatives:\n - name: A\n features:\n - name: F\n requirements:\n - name: email\n type: email\n value: \"{user.email}\"\n - name: navn\n value: \"{user.name}\"\n - name: hva\n type: textarea\n value: \"{user.email} says\"\n";
let q: Question = serde_yaml::from_str(yaml).unwrap();
let user = crate::auth::User { sub: "s".into(), username: "kari".into(), display_name: "Kari N".into(), groups: vec![], email: Some("kari@vel.no".into()) };
let mut signed_in = q.clone();
fill_from_user(&mut signed_in, Some(&user));
let reqs = &signed_in.alternatives[0].features[0].requirements;
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("hidden", Some("kari@vel.no")));
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("hidden", Some("Kari N")));
// Mixed text is not a user field, and is left as written.
assert_eq!(reqs[2].value.as_deref(), Some("{user.email} says"));
// A visitor is asked: the placeholder goes, the field stays.
let mut visitor = q.clone();
fill_from_user(&mut visitor, None);
let reqs = &visitor.alternatives[0].features[0].requirements;
assert_eq!((reqs[0].kind.as_str(), reqs[0].value.as_deref()), ("email", None));
assert_eq!((reqs[1].kind.as_str(), reqs[1].value.as_deref()), ("text", None));
}
#[test]
fn markdown_drops_html_and_unsafe_links() {
assert_eq!(render_inline_markdown("x <script>y</script> z"), "x y z");
+21
View File
@@ -135,6 +135,27 @@ pub fn validate_questions(
}
}
// A mail to the record this one belongs to goes through a field of
// the form: `{notice.email}` needs a `type: record` field called
// `notice` on every form that records into the bucket, or the mail
// has nowhere to go.
for (bucket, schema) in aggregates {
for (state, spec) in &schema.mail_for_state {
let Some(parent) = crate::mail::parent_of(&spec.to) else { continue };
for question in questions.values() {
for alternative in question.alternatives.iter().filter(|a| a.record_as.as_deref() == Some(bucket.as_str())) {
let has = alternative.features.iter().flat_map(|f| &f.requirements).any(|r| r.name == parent && r.kind == "record" && r.of.is_some());
if !has {
anyhow::bail!(
"question {:?} alternative {:?}: records into {bucket:?}, whose state {state:?} mails {:?}, so it must carry a `type: record` field called {parent:?} with `of`",
question.id, alternative.name, spec.to
);
}
}
}
}
}
// A state that grants a group makes an account for the person the
// record is about, so the record has to know who they are. Same
// shape as an invite's own rule, one step further back: the form
+63 -2
View File
@@ -251,6 +251,49 @@ pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Val
vars
}
/// The record field a `to` reaches through, when it is neither
/// `submitter` nor an address: `{notice.email}` gives `notice`.
pub fn parent_of(to: &str) -> Option<&str> {
let inner = to.trim().strip_prefix('{')?.strip_suffix('}')?;
let (field, _) = inner.split_once('.')?;
(!field.is_empty() && !inner.contains(char::is_whitespace)).then_some(field)
}
/// The fields of every record this one belongs to, as `{field.name}`:
/// for each `type: record` field of the forms that record into
/// `bucket`, the record it names, read from its own bucket. A
/// reference that does not resolve adds nothing, and a template that
/// needed it renders empty there.
async fn parent_vars(state: &crate::server::AppState, bucket: &str, responses: &serde_json::Value, vars: &mut serde_json::Map<String, serde_json::Value>) {
let questions = state.questions.load();
let refs: Vec<(String, String)> = questions
.values()
.flat_map(|q| q.alternatives.iter())
.filter(|a| a.record_as.as_deref() == Some(bucket))
.flat_map(|a| a.features.iter().flat_map(|f| f.requirements.iter()))
.filter(|r| r.kind == "record")
.filter_map(|r| Some((r.name.clone(), r.of.clone()?)))
.collect();
for (field, of) in refs {
let Some(id) = responses.get(&field).and_then(|v| v.as_str()).filter(|s| !s.is_empty()) else { continue };
let Ok(store) = state.jetstream.get_key_value(&of).await else { continue };
let Ok(Some(bytes)) = store.get(id).await else { continue };
let Ok(parent) = serde_json::from_slice::<crate::answers::Answer>(&bytes) else { continue };
if let Some(fields) = parent.responses.as_object() {
for (k, v) in fields {
let text = match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
};
vars.insert(format!("{field}.{k}"), text.into());
}
}
vars.insert(format!("{field}.id"), parent.id.into());
vars.insert(format!("{field}.state"), parent.state.into());
}
}
async fn publish(js: &async_nats::jetstream::Context, mail: &Outgoing) -> anyhow::Result<()> {
let mut headers = async_nats::HeaderMap::new();
headers.insert("Nats-Msg-Id", mail.id.as_str());
@@ -277,7 +320,8 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
tracing::warn!(bucket, to_state, "a mail is declared for this state, but site.yaml has no `mail.from` - not sent");
return;
};
let vars = vars_for(bucket, id, to_state, responses);
let mut vars = vars_for(bucket, id, to_state, responses);
parent_vars(state, bucket, responses, &mut vars).await;
let to = if spec.to_submitter() {
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
Some(e) => e.to_string(),
@@ -287,7 +331,13 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
}
}
} else {
spec.to.clone()
// A field of the record this one belongs to, or an address.
let to = render(&spec.to, &vars);
if !to.contains('@') {
tracing::warn!(bucket, id, to_state, to = %spec.to, "the mail's address did not resolve to one - not sent");
return;
}
to
};
let text = render(&spec.body, &vars);
let mail = Outgoing {
@@ -588,6 +638,17 @@ mod tests {
}
}
#[test]
fn a_mail_reaches_through_a_record_field_and_nothing_else() {
assert_eq!(parent_of("{notice.email}"), Some("notice"));
assert_eq!(parent_of(" {case.contact} "), Some("case"));
assert_eq!(parent_of("submitter"), None);
assert_eq!(parent_of("post@vel.no"), None);
assert_eq!(parent_of("{email}"), None);
assert_eq!(parent_of("{.email}"), None);
assert_eq!(parent_of("{not a.field}"), None);
}
#[test]
fn templates_take_fields_and_leave_stray_braces_alone() {
let responses = serde_json::json!({ "name": "Kari", "email": " kari@x.no ", "count": 3, "hva": null });
+17 -41
View File
@@ -298,47 +298,7 @@ main.not-found {
}
/* The way down the tree: one level toward the front door, from any
page. Fixed at the left edge, half-way up, over the margin rather
than the text; small, and half-faded until touched, so it never
covers what a visitor is reading. */
.down-the-tree {
position: fixed;
left: 0.6rem;
top: 50%;
transform: translateY(-50%);
z-index: 20;
width: 2.6rem;
height: 2.6rem;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--card-bg-opaque);
color: var(--ink);
box-shadow: 0 0.2rem 0.8rem var(--card-shadow);
opacity: 0.7;
transition: opacity 120ms, transform 120ms;
}
.down-the-tree:hover,
.down-the-tree:focus-visible {
opacity: 1;
transform: translateY(-50%) translateX(-0.12rem);
}
.down-the-tree svg {
width: 1.3rem;
height: 1.3rem;
}
@media (max-width: 43.75rem) {
.down-the-tree {
left: 0.25rem;
width: 2.2rem;
height: 2.2rem;
opacity: 0.55;
}
}
page, as a line at the foot of it. */
.down-the-tree-foot {
position: relative;
z-index: 1;
@@ -1134,6 +1094,22 @@ textarea:focus {
color: var(--accent);
}
/* The page about a listed record, when the list offers one. */
.answer-link {
display: inline-block;
margin-top: 0.4rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--accent);
text-decoration: none;
border-bottom: 0.1rem solid var(--accent);
}
.answer-link:hover {
color: var(--ink);
border-bottom-color: var(--ink);
}
.answer-actions {
display: flex;
flex-direction: column;