A reply to a case mail becomes a note on its record
Test / test (push) Failing after 53s
Publish release / publish (push) Successful in 3m14s

Whatever reads the mailbox publishes on portal.mail.received; portal
appends the note after checking the record exists in a bucket this
site has and the sender is the address the record itself holds. A
note never moves a case: a mailbox is the one door anyone may knock
on, so it cannot drive a state machine even if someone forges their
way past the checks. The desks show the conversation beside the case.

Tested: the address parser, every refusal (a stranger, no address to
compare, an empty reply, the same reply twice, an unknown contract),
and the whole path through a real JetStream - the note lands, the
state does not move, and no event is written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-24 21:26:57 +02:00
co-authored by Claude Opus 5
parent 4fd60293fa
commit 26c1762dfa
9 changed files with 337 additions and 3 deletions
+26 -1
View File
@@ -1158,6 +1158,7 @@ fn AlternativeCard(
let question_id = question_id.clone();
let alt_name = alt_name.clone();
let feature_name = feature_name.clone();
let _place = place.clone();
move |req| {
let field_id = format!("{}-{}", field_prefix, req.name);
let label_for = field_id.clone();
@@ -1329,7 +1330,7 @@ fn AlternativeCard(
.value
.clone()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| place.clone());
.unwrap_or_else(|| _place.clone());
let widget: StoredValue<Option<map::MapWidget>, LocalStorage> =
StoredValue::new_local(None);
Effect::new(move |_| {
@@ -1975,6 +1976,7 @@ fn AnswerRow(
.cloned()
.unwrap_or_default();
let submitted_ms = answer.submitted_ms;
let notes = answer.notes.clone();
let state = answer.state.clone();
let item_id = answer.id.clone();
let key = (feature_name.clone(), item_id.clone());
@@ -2025,6 +2027,29 @@ fn AnswerRow(
<span class="answer-state">{state}</span>
<span class="answer-time">{format_ms(submitted_ms)}</span>
</div>
// 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.
{(!notes.is_empty())
.then(|| {
view! {
<ol class="answer-notes">
<For
each=move || notes.clone()
key=|note| note.id.clone()
children=move |note| {
view! {
<li class="answer-note">
<span class="answer-note-who">{note.from}</span>
<span class="answer-note-time">{format_ms(note.received_ms)}</span>
<p class="answer-note-text">{note.text}</p>
</li>
}
}
/>
</ol>
}
})}
<Show when={
let available = available.clone();
move || !available.is_empty()