Mail is content: a state's mail: and the invite mail, published for gdo
`mail: { to, subject, body }` on a state in aggregates.yaml sends when a
record enters that state, `to: submitter` or one address, templates
over the record's fields plus {site}, {chain}, {email}. An invite mails
its one-time link built in, reworded by `mail.invite` in site.yaml,
which also carries the sender (`mail.from`, `mail.reply_to`); without
it a site sends nothing and says so in the log. Portal renders the
text and an HTML alternative and publishes one message per state
change on portal.mail.send in the WORMHOLE stream, id <bucket>:<record>
:<state> so a retried transition sends once; uhhm/gdo holds the
durable consumer and talks SMTP. The lint requires a required email
field on every form recording into a bucket that mails the submitter.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6d60f8b9a3
commit
19c8f541de
@@ -36,6 +36,32 @@ pub struct AggregateSchema {
|
||||
/// rule has an explicit, auditable opt-out instead of a silent
|
||||
/// one, and so the next reader knows where the answers go.
|
||||
pub attended_by: Option<String>,
|
||||
/// The mail a record's arrival in a state sends (see `mail.rs`):
|
||||
/// declared on the state in aggregates.yaml, next to the event
|
||||
/// that enters it, so content decides when a person hears
|
||||
/// something and the mailer never has to know a site.
|
||||
pub mail_for_state: HashMap<String, MailSpec>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MailSpec {
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl MailSpec {
|
||||
pub fn to_submitter(&self) -> bool {
|
||||
self.to == "submitter"
|
||||
}
|
||||
}
|
||||
|
||||
impl AggregateSchema {
|
||||
@@ -43,6 +69,10 @@ impl AggregateSchema {
|
||||
self.event_for_state.contains_key(state)
|
||||
}
|
||||
|
||||
pub fn mail_for(&self, state: &str) -> Option<&MailSpec> {
|
||||
self.mail_for_state.get(state)
|
||||
}
|
||||
|
||||
pub fn allowed(&self, state: &str) -> &[String] {
|
||||
self.transitions
|
||||
.get(state)
|
||||
@@ -54,6 +84,8 @@ impl AggregateSchema {
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct RawState {
|
||||
event: String,
|
||||
#[serde(default)]
|
||||
mail: Option<MailSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -123,6 +155,22 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
}
|
||||
}
|
||||
|
||||
let mut mail_for_state = HashMap::new();
|
||||
for (state, raw_state) in raw_schema.states {
|
||||
if let Some(mail) = raw_state.mail {
|
||||
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)) {
|
||||
anyhow::bail!(
|
||||
"aggregates.yaml: bucket {bucket:?}: state {state:?}: mail.to must be `submitter` or one address, not {:?}",
|
||||
mail.to
|
||||
);
|
||||
}
|
||||
mail_for_state.insert(state, mail);
|
||||
}
|
||||
}
|
||||
|
||||
out.insert(
|
||||
bucket.clone(),
|
||||
AggregateSchema {
|
||||
@@ -132,6 +180,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
state_for_event,
|
||||
transitions: raw_schema.transitions,
|
||||
attended_by: raw_schema.attended_by,
|
||||
mail_for_state,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -349,6 +398,37 @@ aggregates:
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_may_carry_the_mail_its_arrival_sends() {
|
||||
let schemas = parse_aggregates_yaml(
|
||||
r#"
|
||||
aggregates:
|
||||
- bucket: reports
|
||||
initial: open
|
||||
states:
|
||||
open: { event: received, mail: { to: submitter, subject: "Got it, {name}", body: "We read it. {site}/decide?chain={chain}" } }
|
||||
fixed: { event: fixed, mail: { to: board@example.no, subject: "Fixed: {where}", body: "Done." } }
|
||||
dropped: { event: dropped }
|
||||
transitions:
|
||||
open: [fixed, dropped]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = &schemas["reports"];
|
||||
assert!(s.mail_for("open").unwrap().to_submitter());
|
||||
assert_eq!(s.mail_for("fixed").unwrap().to, "board@example.no");
|
||||
assert!(s.mail_for("dropped").is_none());
|
||||
|
||||
for bad in [
|
||||
"mail: { to: submitter, subject: \"\", body: b }",
|
||||
"mail: { to: \"the board\", subject: s, body: b }",
|
||||
"mail: { to: submitter, subject: s, body: b, cc: x }",
|
||||
] {
|
||||
let yaml = format!("aggregates:\n - bucket: r\n initial: open\n states:\n open: {{ event: received, {bad} }}\n");
|
||||
assert!(parse_aggregates_yaml(&yaml).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
fn event(seq: u64, event_type: &str) -> Event {
|
||||
Event {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
|
||||
Reference in New Issue
Block a user