2026-09-23 19:45:48 +02:00
//! Portal's side of the mail gate. Content decides when a person
//! hears something (`mail:` on a state in aggregates.yaml, and the
//! built-in invite mail); portal renders the message the moment a
//! record enters that state and publishes it on one JetStream subject.
//! gdo (uhhm/gdo) holds a durable consumer on that subject and hands
//! each message to the host's SMTP. Portal never talks SMTP, gdo never
//! reads content, and a message survives either of them being down.
//!
//! The message is the contract between the two, `Outgoing` below,
//! versioned by `v`. Its id is the JetStream message id, so a
//! transition that fires twice (a retry, a reseed) sends once.
#![cfg(feature = "ssr" )]
use serde ::{ Deserialize , Serialize };
use std ::time ::Duration ;
/// The stream both sides create on boot, with the same config, so
/// whichever starts first owns nothing the other lacks.
2026-09-23 21:15:40 +02:00
pub const STREAM : & str = "mail" ;
2026-09-23 19:45:48 +02:00
pub const SUBJECTS : & str = "portal.mail.>" ;
pub const SUBJECT : & str = "portal.mail.send" ;
2026-09-24 21:26:57 +02:00
/// Where a reply to a case mail arrives, published by whatever reads
/// the mailbox (uhhm/gdo over JMAP). Portal turns it into a note on
/// the record it names and nothing else: no state moves on a reply.
pub const RECEIVED_SUBJECT : & str = "portal.mail.received" ;
2026-09-23 19:45:48 +02:00
/// Bumped when `Outgoing` changes shape; gdo refuses what it does not
/// know rather than guessing.
pub const CONTRACT : u8 = 1 ;
/// One mail, fully rendered: everything gdo needs and nothing it has
/// to look up.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Outgoing {
pub v : u8 ,
/// `<bucket>:<record>:<state>` or `invite:<user>:<group>:<ms>` -
/// the dedup key.
pub id : String ,
pub site : String ,
pub from : String ,
#[serde(default)]
pub reply_to : Option < String > ,
pub to : String ,
pub subject : String ,
pub text : String ,
pub html : String ,
}
2026-09-24 21:26:57 +02:00
/// A reply that came back, already matched to a record by whatever
/// read the mailbox. Portal trusts the match no further than it can
/// check it: the record must exist, the bucket must be one this site
/// has, and the sender must be the address the record itself holds.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Incoming {
pub v : u8 ,
/// The mail's own id, so the same reply read twice lands once.
pub id : String ,
pub bucket : String ,
pub record : String ,
/// Who sent it, as the mail server saw it.
pub from : String ,
#[serde(default)]
pub subject : String ,
/// The reply itself, already stripped of quoted history and
/// signatures by the reader; portal stores what it is given.
pub text : String ,
pub received_ms : i64 ,
}
/// What a reply may add to a record, and what it may never do. Returns
/// the note to append, or why the reply is not one.
///
/// The address a reply comes back on is unguessable, so it is proof of
/// having been sent the mail - but a mailbox is a public door, and an
/// address can be forged. The sender must therefore also match the
/// address the record already holds, which is the one portal wrote to
/// in the first place.
pub fn note_for ( incoming : & Incoming , answer : & crate ::answers ::Answer ) -> Result < crate ::answers ::Note , String > {
if incoming . v != CONTRACT {
return Err ( format! ( "reply contract v {} is not v {CONTRACT} " , incoming . v ));
}
let text = incoming . text . trim ();
if text . is_empty () {
return Err ( "the reply has no text" . to_string ());
}
let held = answer
. responses
. get ( "email" )
. and_then ( | v | v . as_str ())
. map ( | e | e . trim (). to_lowercase ())
. filter ( | e | ! e . is_empty ())
. ok_or_else ( || "the record holds no address to compare the sender with" . to_string ()) ? ;
if address_of ( & incoming . from ) != held {
return Err ( format! ( " {} is not the address this record was written to" , incoming . from ));
}
if answer . notes . iter (). any ( | n | n . id == incoming . id ) {
return Err ( "this reply is already on the record" . to_string ());
}
Ok ( crate ::answers ::Note {
id : incoming . id . clone (),
from : address_of ( & incoming . from ),
subject : incoming . subject . trim (). to_string (),
text : text . to_string (),
received_ms : incoming . received_ms ,
})
}
/// The bare address out of `Name <a@b>` or `a@b`, folded for
/// comparison. Mail addresses are case-insensitive in the part that
/// matters here, and a display name is not identity.
pub fn address_of ( from : & str ) -> String {
let inner = match ( from . rfind ( '<' ), from . rfind ( '>' )) {
( Some ( open ), Some ( close )) if close > open => & from [ open + 1 .. close ],
_ => from ,
};
inner . trim (). to_lowercase ()
}
/// Listens for replies and writes each one onto the record it names.
/// A reply is a note and never a decision: nothing here moves a case,
/// so a mailbox - the one door anyone may knock on - cannot drive a
/// state machine even if someone forges their way past the checks.
pub async fn receive ( state : crate ::server ::AppState ) {
use futures ::StreamExt ;
let mut sub = match state . nats . subscribe ( RECEIVED_SUBJECT ). await {
Ok ( sub ) => sub ,
Err ( e ) => {
tracing ::error! ( error = % e , "cannot listen for replies" );
return ;
}
};
tracing ::info! ( subject = RECEIVED_SUBJECT , "listening for replies to case mail" );
while let Some ( msg ) = sub . next (). await {
let incoming : Incoming = match serde_json ::from_slice ( & msg . payload ) {
Ok ( i ) => i ,
Err ( e ) => {
tracing ::warn! ( "unreadable reply on {RECEIVED_SUBJECT}: {e}" );
continue ;
}
};
match note_onto ( & state , & incoming ). await {
Ok (()) => tracing ::info! ( bucket = % incoming . bucket , record = % incoming . record , "a reply was added to the record" ),
Err ( e ) => tracing ::warn! ( bucket = % incoming . bucket , record = % incoming . record , "reply refused: {e}" ),
}
}
}
/// The record's own bucket is the only one a reply may touch, and the
/// note is appended under the same read-modify-write the desks use.
async fn note_onto ( state : & crate ::server ::AppState , incoming : & Incoming ) -> anyhow ::Result < () > {
if ! state . aggregates . load (). contains_key ( & incoming . bucket ) && ! crate ::content ::bucket_is_read ( & state . questions . load (), & incoming . bucket ) {
anyhow ::bail! ( "{} is not a bucket of this site" , incoming . bucket );
}
let store = state . jetstream . get_key_value ( & incoming . bucket ). await ? ;
let bytes = store
. get ( & incoming . record )
. await ?
. ok_or_else ( || anyhow ::anyhow! ( "no such record" )) ? ;
let mut answer : crate ::answers ::Answer = serde_json ::from_slice ( & bytes ) ? ;
let note = note_for ( incoming , & answer ). map_err ( | e | anyhow ::anyhow! ( e )) ? ;
answer . notes . push ( note );
store . put ( & incoming . record , serde_json ::to_vec ( & answer ) ? . into ()). await ? ;
Ok (())
}
2026-09-23 19:45:48 +02:00
/// Idempotent, called by portal at boot and by gdo. A week of
/// retention: a mail nobody delivered in a week is stale, not owed.
/// The duplicate window has to fit inside it.
pub async fn ensure_stream ( js : & async_nats ::jetstream ::Context ) -> anyhow ::Result < async_nats ::jetstream ::stream ::Stream > {
Ok ( js
. get_or_create_stream ( async_nats ::jetstream ::stream ::Config {
name : STREAM . to_string (),
subjects : vec ! [ SUBJECTS . to_string ()],
max_age : Duration ::from_secs ( 7 * 24 * 3600 ),
duplicate_window : Duration ::from_secs ( 24 * 3600 ),
.. Default ::default ()
})
. await ? )
}
/// `{key}` in a template becomes the variable's value, an unknown key
/// becomes nothing, and a brace that opens no key stays a brace.
pub fn render ( template : & str , vars : & serde_json ::Map < String , serde_json ::Value > ) -> String {
let mut out = String ::with_capacity ( template . len ());
let mut rest = template ;
while let Some ( start ) = rest . find ( '{' ) {
out . push_str ( & rest [ .. start ]);
let after = & rest [ start + 1 .. ];
let key_len = after
. char_indices ()
. take_while ( | ( _ , c ) | c . is_ascii_alphanumeric () || * c == '_' || * c == '-' || * c == '.' )
. last ()
. map ( | ( i , c ) | i + c . len_utf8 ())
. unwrap_or ( 0 );
if key_len > 0 && after [ key_len .. ]. starts_with ( '}' ) {
let key = & after [ .. key_len ];
match vars . get ( key ) {
Some ( serde_json ::Value ::String ( s )) => out . push_str ( s ),
Some ( serde_json ::Value ::Null ) | None => {}
Some ( other ) => out . push_str ( & other . to_string ()),
}
rest = & after [ key_len + 1 .. ];
} else {
out . push ( '{' );
rest = after ;
}
}
out . push_str ( rest );
out
}
/// The HTML alternative: the markdown body as a single column with
/// inline styles, which is as much as mail clients agree on. Raw HTML
/// in the body is dropped, the same rule as page descriptions.
pub fn to_html ( markdown : & str ) -> String {
use pulldown_cmark ::{ html , Event , Options , Parser };
let parser = Parser ::new_ext ( markdown , Options ::empty ()). filter ( | e | ! matches! ( e , Event ::Html ( _ ) | Event ::InlineHtml ( _ )));
let mut body = String ::new ();
html ::push_html ( & mut body , parser );
format! (
"<!doctype html><html><body style= \" margin:0;padding:1.5em;background:#f4f4f2;color:#1c1c1a;font:16px/1.5 -apple-system,Segoe UI,Helvetica,Arial,sans-serif \" ><div style= \" max-width:36em;margin:0 auto;background:#fff;padding:1.5em 2em;border-radius:0.5em \" > {body} </div></body></html>"
)
}
fn public_url () -> String {
std ::env ::var ( "PUBLIC_URL" ). unwrap_or_default (). trim_end_matches ( '/' ). to_string ()
}
fn site_name ( site : & crate ::content ::SiteConfig ) -> String {
site . title . clone (). unwrap_or_else ( || option_env! ( "SITE_NAME" ). unwrap_or ( "portal" ). to_string ())
}
/// The variables a state mail may use: every field of the record,
/// then the runtime's own.
pub fn vars_for ( bucket : & str , id : & str , state : & str , responses : & serde_json ::Value ) -> serde_json ::Map < String , serde_json ::Value > {
let mut vars = serde_json ::Map ::new ();
if let Some ( fields ) = responses . as_object () {
for ( k , v ) in fields {
vars . insert ( k . clone (), match v {
serde_json ::Value ::String ( s ) => serde_json ::Value ::String ( s . clone ()),
serde_json ::Value ::Null => serde_json ::Value ::String ( String ::new ()),
other => serde_json ::Value ::String ( other . to_string ()),
});
}
}
let email = responses . get ( "email" ). and_then ( | v | v . as_str ()). unwrap_or_default (). trim (). to_string ();
vars . insert ( "email" . into (), email . into ());
vars . insert ( "site" . into (), public_url (). into ());
vars . insert ( "chain" . into (), id . into ());
vars . insert ( "bucket" . into (), bucket . into ());
vars . insert ( "state" . into (), state . into ());
vars
}
2026-09-30 06:47:36 +02:00
/// 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 ());
}
}
2026-09-23 19:45:48 +02:00
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 ());
let bytes = serde_json ::to_vec ( mail ) ? ;
js . publish_with_headers ( SUBJECT , headers , bytes . into ()). await ? . await ? ;
Ok (())
}
/// A record entered `state` in `bucket`: send what the schema says,
/// if anything. Best-effort by design - the transition is already
/// committed, and a mail that cannot be rendered is logged, never a
/// failed request.
pub async fn on_state ( state : & crate ::server ::AppState , bucket : & str , id : & str , to_state : & str , responses : & serde_json ::Value ) {
let Some ( spec ) = state . aggregates . load (). get ( bucket ). and_then ( | s | s . mail_for ( to_state )). cloned () else {
2026-09-29 21:00:10 +02:00
// A suggestion has its own words for this unless the content
// gave the state a mail, which then is the one sent.
if bucket == crate ::content ::EDIT_SUGGESTIONS_BUCKET {
on_suggestion_state ( state , id , to_state , responses ). await ;
}
2026-09-23 19:45:48 +02:00
return ;
};
let site = state . site . load ();
let Some ( cfg ) = & site . mail else {
tracing ::warn! ( bucket , to_state , "a mail is declared for this state, but site.yaml has no `mail.from` - not sent" );
return ;
};
2026-09-30 06:47:36 +02:00
let mut vars = vars_for ( bucket , id , to_state , responses );
parent_vars ( state , bucket , responses , & mut vars ). await ;
2026-09-23 19:45:48 +02:00
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 (),
None => {
tracing ::warn! ( bucket , id , to_state , "mail to the submitter, but the record carries no email - not sent" );
return ;
}
}
} else {
2026-09-30 06:47:36 +02:00
// 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
2026-09-23 19:45:48 +02:00
};
let text = render ( & spec . body , & vars );
let mail = Outgoing {
v : CONTRACT ,
id : format ! ( "{bucket}:{id}:{to_state}" ),
site : site_name ( & site ),
from : cfg . from . clone (),
reply_to : cfg . reply_to . clone (),
to ,
subject : render ( & spec . subject , & vars ),
html : to_html ( & text ),
text ,
};
if let Err ( e ) = publish ( & state . jetstream , & mail ). await {
tracing ::error! ( bucket , id , to_state , "publishing the mail failed: {e}" );
}
}
2026-09-29 21:00:10 +02:00
/// The questions a person answers for, as a mail names them: each in
/// quotes, and past the first three only how many more - someone who
/// answers for a whole site is not read its table of contents.
pub fn questions_listed ( questions : & [ String ], norwegian : bool ) -> String {
let quote = | q : & String | if norwegian { format! ( "« {q} »" ) } else { format! ( " \u{201c} {q} \u{201d} " ) };
let and = if norwegian { "og" } else { "and" };
match questions {
[] => String ::new (),
[ one ] => quote ( one ),
few if few . len () <= 3 => {
let quoted : Vec < String > = few . iter (). map ( quote ). collect ();
format! ( " {} {and} {} " , quoted [ .. quoted . len () - 1 ]. join ( ", " ), quoted [ quoted . len () - 1 ])
}
many => {
let quoted : Vec < String > = many [ .. 3 ]. iter (). map ( quote ). collect ();
let more = many . len () - 3 ;
let others = match ( norwegian , more ) {
( true , 1 ) => "ett spørsmål til" . to_string (),
( true , n ) => format! ( " {n} spørsmål til" ),
( false , 1 ) => "one more question" . to_string (),
( false , n ) => format! ( " {n} more questions" ),
};
format! ( " {} {and} {others} " , quoted . join ( ", " ))
}
}
}
2026-09-29 15:44:20 +02:00
/// The mail a person gets when the content names them responsible for
/// a question and they had no account: who they are to the site (the
/// person listed as asking that question), and the link that lets them
/// sign in and suggest changes to it. Built in, in the site's language.
pub async fn on_responsible ( state : & crate ::server ::AppState , invited : & crate ::people ::Invited , questions : & [ String ]) {
let site = state . site . load ();
let Some ( cfg ) = & site . mail else {
tracing ::warn! ( user = % invited . username , "an account was made for a responsible person, but site.yaml has no `mail.from` - they were not told" );
return ;
};
let norwegian = site . lang . as_deref (). is_some_and ( | l | l . starts_with ( "no" ) || l . starts_with ( "nb" ) || l . starts_with ( "nn" ));
2026-09-29 21:00:10 +02:00
let list = questions_listed ( questions , norwegian );
2026-09-29 15:44:20 +02:00
let ( subject , body ) = if norwegian {
(
"Du står som ansvarlig for et spørsmål hos {site_name}" . to_string (),
"Hei {name}, \n\n Du står oppført som ansvarlig for å stille {questions} hos {site_name}. Derfor har du fått en konto der, så du kan logge inn og foreslå endringer i det du står bak. \n\n Velg passord eller passnøkkel her, lenken virker fram til {expires}: \n\n {link} \n\n Brukernavnet ditt er {username}. Etterpå logger du inn på {site}." . to_string (),
)
} else {
(
"You are listed as responsible for a question at {site_name}" . to_string (),
"Hello {name}, \n\n You are listed as responsible for asking {questions} at {site_name}. So you now have an account there, to sign in and suggest changes to what you stand behind. \n\n Set your password or passkey here; the link works until {expires}: \n\n {link} \n\n Your username is {username}. Afterwards you sign in at {site}." . to_string (),
)
};
let now_ms = chrono ::Utc ::now (). timestamp_millis ();
let expires = invited
. link_expires_ms
. and_then ( chrono ::DateTime ::< chrono ::Utc > ::from_timestamp_millis )
. map ( | t | t . format ( "%Y-%m-%d %H:%M UTC" ). to_string ())
. unwrap_or_default ();
let mut vars = serde_json ::Map ::new ();
vars . insert ( "name" . into (), invited . display_name . clone (). into ());
vars . insert ( "username" . into (), invited . username . clone (). into ());
vars . insert ( "questions" . into (), list . into ());
vars . insert ( "link" . into (), invited . reset_link . clone (). unwrap_or_default (). into ());
vars . insert ( "site" . into (), public_url (). into ());
vars . insert ( "site_name" . into (), site_name ( & site ). into ());
vars . insert ( "expires" . into (), expires . into ());
let text = render ( & body , & vars );
let mail = Outgoing {
v : CONTRACT ,
id : format ! ( "responsible:{}:{now_ms}" , invited . username ),
site : site_name ( & site ),
from : cfg . from . clone (),
reply_to : cfg . reply_to . clone (),
to : invited . email . clone (),
subject : render ( & subject , & vars ),
html : to_html ( & text ),
text ,
};
if let Err ( e ) = publish ( & state . jetstream , & mail ). await {
tracing ::error! ( user = % invited . username , "publishing the responsible mail failed: {e}" );
}
}
2026-09-29 21:00:10 +02:00
/// What the person who suggested a change hears when it moves, as
/// subject and body; `None` for a state that says nothing new to them
/// (it arriving, or one the content added). `{name}`, `{question}`,
/// `{page}`, `{quoted}`.
pub fn suggestion_words ( to_state : & str , norwegian : bool ) -> Option < ( & 'static str , & 'static str ) > {
Some ( match ( to_state , norwegian ) {
( "discussed" , true ) => (
"Forslaget ditt om «{question}» blir sett på" ,
"Hei {name}, \n\n Forslaget ditt til endring på {page} er tatt opp, og blir sett på nå. Du får beskjed når det er avgjort. \n\n Du skrev: \n\n {quoted}" ,
),
( "done" , true ) => (
"Endringen du foreslo i «{question}» er gjort" ,
"Hei {name}, \n\n Endringen du foreslo på {page} er gjort. Takk for at du sa fra. \n\n Du skrev: \n\n {quoted}" ,
),
( "declined" , true ) => (
"Forslaget ditt om «{question}» blir ikke gjort" ,
"Hei {name}, \n\n Forslaget ditt til endring på {page} blir ikke gjort slik det står. Svar på denne e-posten om du vil vite hvorfor, eller vil foreslå det på en annen måte. \n\n Du skrev: \n\n {quoted}" ,
),
( "discussed" , false ) => (
"Your suggestion about \u{201c} {question} \u{201d} is being looked at" ,
"Hello {name}, \n\n Your suggested change to {page} has been taken up and is being looked at. You will hear when it is settled. \n\n You wrote: \n\n {quoted}" ,
),
( "done" , false ) => (
"The change you suggested to \u{201c} {question} \u{201d} is made" ,
"Hello {name}, \n\n The change you suggested to {page} is made. Thank you for saying so. \n\n You wrote: \n\n {quoted}" ,
),
( "declined" , false ) => (
"Your suggestion about \u{201c} {question} \u{201d} will not be made" ,
"Hello {name}, \n\n Your suggested change to {page} will not be made as it stands. Answer this mail if you want to know why, or to suggest it another way. \n\n You wrote: \n\n {quoted}" ,
),
_ => return None ,
})
}
/// A suggestion moved: tell whoever made it. Replies go to the site's
/// inbox, which is who decided.
async fn on_suggestion_state ( state : & crate ::server ::AppState , id : & str , to_state : & str , responses : & serde_json ::Value ) {
let site = state . site . load ();
let norwegian = site . lang . as_deref (). is_some_and ( | l | l . starts_with ( "no" ) || l . starts_with ( "nb" ) || l . starts_with ( "nn" ));
let Some (( subject , body )) = suggestion_words ( to_state , norwegian ) else {
return ;
};
let Some ( cfg ) = & site . mail else {
tracing ::warn! ( id , to_state , "a suggestion moved, but site.yaml has no `mail.from` - whoever made it was not told" );
return ;
};
let field = | name : & str | responses . get ( name ). and_then ( | v | v . as_str ()). unwrap_or_default (). trim (). to_string ();
let to = field ( "email" );
if ! to . contains ( '@' ) {
tracing ::warn! ( id , to_state , "a suggestion moved, but the record carries no email - not sent" );
return ;
}
let quoted : String = field ( "text" ). lines (). map ( | l | format! ( "> {l} " )). collect ::< Vec < _ >> (). join ( " \n " );
let mut vars = serde_json ::Map ::new ();
vars . insert ( "name" . into (), field ( "name" ). into ());
vars . insert ( "question" . into (), field ( "question" ). into ());
vars . insert ( "page" . into (), format! ( " {}{} " , public_url (), field ( "page" )). into ());
vars . insert ( "quoted" . into (), quoted . into ());
let text = render ( body , & vars );
let mail = Outgoing {
v : CONTRACT ,
id : format ! ( "{}:{id}:{to_state}" , crate ::content ::EDIT_SUGGESTIONS_BUCKET ),
site : site_name ( & site ),
from : cfg . from . clone (),
reply_to : cfg . reply_to . clone (),
to ,
subject : render ( subject , & vars ),
html : to_html ( & text ),
text ,
};
if let Err ( e ) = publish ( & state . jetstream , & mail ). await {
tracing ::error! ( id , to_state , "publishing the suggestion mail failed: {e}" );
}
}
2026-09-29 15:44:20 +02:00
/// A change suggested by the person responsible for a page, to the
/// site's own inbox (`mail.reply_to`, else `mail.from`), with the
/// suggester as reply-to so answering the mail answers them.
pub async fn on_edit_suggestion ( state : & crate ::server ::AppState , question_id : & str , question_name : & str , by_name : & str , by_email : & str , text : & str ) {
let site = state . site . load ();
let Some ( cfg ) = & site . mail else {
tracing ::warn! ( question_id , "an edit was suggested, but site.yaml has no `mail.from` - it is in portal_edit_suggestions only" );
return ;
};
let to = cfg . reply_to . clone (). unwrap_or_else ( || cfg . from . clone ());
let norwegian = site . lang . as_deref (). is_some_and ( | l | l . starts_with ( "no" ) || l . starts_with ( "nb" ) || l . starts_with ( "nn" ));
let ( subject , lead ) = if norwegian {
( format! ( "Forslag til endring: « {question_name} »" ), format! ( " {by_name} ( {by_email} ), som står som ansvarlig for spørsmålet, foreslår en endring på {}{question_id} :" , public_url ()))
} else {
( format! ( "Suggested change: \u{201c} {question_name} \u{201d} " ), format! ( " {by_name} ( {by_email} ), listed as responsible for the question, suggests a change to {}{question_id} :" , public_url ()))
};
let body = format! ( " {lead} \n\n {text} " );
let now_ms = chrono ::Utc ::now (). timestamp_millis ();
let mail = Outgoing {
v : CONTRACT ,
id : format ! ( "suggestion:{question_id}:{now_ms}" ),
site : site_name ( & site ),
from : cfg . from . clone (),
reply_to : Some ( by_email . to_string ()),
to ,
subject ,
html : to_html ( & body ),
text : body ,
};
if let Err ( e ) = publish ( & state . jetstream , & mail ). await {
tracing ::error! ( question_id , "publishing the suggestion mail failed: {e}" );
}
}
2026-09-23 19:45:48 +02:00
/// The invite mail: the one-time link to the person just onboarded.
/// Built in, since portal knows everything about it; `mail.invite` in
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
/// `{site}`, `{expires}`.
pub async fn on_invite ( state : & crate ::server ::AppState , invited : & crate ::people ::Invited ) {
let site = state . site . load ();
let Some ( cfg ) = & site . mail else {
tracing ::warn! ( user = % invited . username , "invite made, but site.yaml has no `mail.from` - the link is on the desk only" );
return ;
};
let norwegian = site . lang . as_deref (). is_some_and ( | l | l . starts_with ( "no" ) || l . starts_with ( "nb" ) || l . starts_with ( "nn" ));
let ( subject , body ) = match ( & cfg . invite , invited . reset_link . is_some (), norwegian ) {
( Some ( t ), _ , _ ) => ( t . subject . clone (), t . body . clone ()),
( None , true , true ) => (
"Du er invitert inn i {group} på {site_name}" . to_string (),
"Hei {name}, \n\n Du har fått en konto på {site} og en plass i {group}. Velg passord eller passnøkkel her, lenken virker fram til {expires}: \n\n {link} \n\n Brukernavnet ditt er {username}." . to_string (),
),
( None , false , true ) => (
"Du har fått plass i {group} på {site_name}" . to_string (),
2026-09-30 13:28:28 +02:00
"Hei {name}, \n\n Du har fått plass i {group} på {site_name}. Logg inn på {site}, så er plassen din der." . to_string (),
2026-09-23 19:45:48 +02:00
),
( None , true , false ) => (
"You are invited into {group} at {site_name}" . to_string (),
"Hello {name}, \n\n You have an account at {site} and a place in {group}. Set your password or passkey here; the link works until {expires}: \n\n {link} \n\n Your username is {username}." . to_string (),
),
( None , false , false ) => (
"You now have a place in {group} at {site_name}" . to_string (),
2026-09-30 13:28:28 +02:00
"Hello {name}, \n\n You now have a place in {group} at {site_name}. Sign in at {site}, and it is there." . to_string (),
2026-09-23 19:45:48 +02:00
),
};
let now_ms = chrono ::Utc ::now (). timestamp_millis ();
let expires = invited
. link_expires_ms
. and_then ( chrono ::DateTime ::< chrono ::Utc > ::from_timestamp_millis )
. map ( | t | t . format ( "%Y-%m-%d %H:%M UTC" ). to_string ())
. unwrap_or_default ();
let mut vars = serde_json ::Map ::new ();
vars . insert ( "name" . into (), invited . display_name . clone (). into ());
vars . insert ( "username" . into (), invited . username . clone (). into ());
vars . insert ( "email" . into (), invited . email . clone (). into ());
vars . insert ( "group" . into (), invited . group . clone (). into ());
vars . insert ( "link" . into (), invited . reset_link . clone (). unwrap_or_default (). into ());
vars . insert ( "site" . into (), public_url (). into ());
vars . insert ( "site_name" . into (), site_name ( & site ). into ());
vars . insert ( "expires" . into (), expires . into ());
let text = render ( & body , & vars );
let mail = Outgoing {
v : CONTRACT ,
id : format ! ( "invite:{}:{}:{now_ms}" , invited . username , invited . group ),
site : site_name ( & site ),
from : cfg . from . clone (),
reply_to : cfg . reply_to . clone (),
to : invited . email . clone (),
subject : render ( & subject , & vars ),
html : to_html ( & text ),
text ,
};
if let Err ( e ) = publish ( & state . jetstream , & mail ). await {
tracing ::error! ( user = % invited . username , "publishing the invite mail failed: {e}" );
}
}
#[cfg(test)]
mod tests {
use super ::* ;
2026-09-29 21:00:10 +02:00
#[test]
fn a_mail_names_three_questions_and_counts_the_rest () {
let q = | n : usize | ( 1 ..= n ). map ( | i | format! ( "Q {i} ?" )). collect ::< Vec < _ >> ();
assert_eq! ( questions_listed ( & q ( 0 ), true ), "" );
assert_eq! ( questions_listed ( & q ( 1 ), true ), "«Q1?»" );
assert_eq! ( questions_listed ( & q ( 3 ), true ), "«Q1?», «Q2?» og «Q3?»" );
assert_eq! ( questions_listed ( & q ( 4 ), true ), "«Q1?», «Q2?», «Q3?» og ett spørsmål til" );
assert_eq! ( questions_listed ( & q ( 17 ), true ), "«Q1?», «Q2?», «Q3?» og 14 spørsmål til" );
assert_eq! ( questions_listed ( & q ( 2 ), false ), " \u{201c} Q1? \u{201d} and \u{201c} Q2? \u{201d} " );
assert_eq! ( questions_listed ( & q ( 5 ), false ), " \u{201c} Q1? \u{201d} , \u{201c} Q2? \u{201d} , \u{201c} Q3? \u{201d} and 2 more questions" );
}
#[test]
fn a_suggestion_says_something_each_time_it_moves_and_nothing_on_arrival () {
for norwegian in [ true , false ] {
for state in [ "discussed" , "done" , "declined" ] {
let ( subject , body ) = suggestion_words ( state , norwegian ). expect ( state );
assert! ( subject . contains ( "{question}" ), "{state}: the subject names the question" );
assert! ( body . contains ( "{page}" ) && body . contains ( "{quoted}" ), "{state}: the body says where and what" );
}
// Arriving is told to the inbox, not back to who sent it.
assert! ( suggestion_words ( "open" , norwegian ). is_none ());
assert! ( suggestion_words ( "a_state_the_content_added" , norwegian ). is_none ());
}
}
2026-09-30 06:47:36 +02:00
#[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 );
}
2026-09-29 21:00:10 +02:00
2026-09-23 19:45:48 +02:00
#[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 });
let vars = vars_for ( "reports" , "abc" , "fixed" , & responses );
assert_eq! ( render ( "Hi {name}, {count} at {site}/decide?chain={chain}&email={email}" , & vars ), "Hi Kari, 3 at /decide?chain=abc&email=kari@x.no" );
assert_eq! ( render ( "{unknown} {hva} {not a key} {" , & vars ), " {not a key} {" );
assert_eq! ( vars [ "bucket" ], "reports" );
assert_eq! ( vars [ "state" ], "fixed" );
}
#[test]
fn the_html_alternative_keeps_markdown_and_drops_raw_html () {
let html = to_html ( "Hello **there** \n\n <script>x()</script> \n\n [link](https://x.no)" );
assert! ( html . contains ( "<strong>there</strong>" ));
assert! ( html . contains ( "<a href= \" https://x.no \" >link</a>" ));
assert! ( ! html . contains ( "<script>" ));
assert! ( html . starts_with ( "<!doctype html>" ));
}
#[test]
fn the_contract_round_trips () {
let mail = Outgoing {
v : CONTRACT ,
id : "reports:abc:fixed" . into (),
site : "Tomter Vel" . into (),
from : "Tomter Vel <vel@example.no>" . into (),
reply_to : None ,
to : "kari@x.no" . into (),
subject : "Fixed" . into (),
text : "Done." . into (),
html : to_html ( "Done." ),
};
let back : Outgoing = serde_json ::from_slice ( & serde_json ::to_vec ( & mail ). unwrap ()). unwrap ();
assert_eq! ( back , mail );
2026-09-24 21:26:57 +02:00
// A gdo that predates reply addresses sends none, and reads
// one from a portal that does: the field is optional on the
// wire in both directions.
let older : Outgoing = serde_json ::from_str (
r #"{"v":1,"id":"a","site":"s","from":"f@x.no","to":"t@x.no","subject":"s","text":"t","html":"h"}"# ,
)
. unwrap ();
assert_eq! ( older . subject , "s" );
assert! ( serde_json ::to_string ( & older ). unwrap (). contains ( " \" html \" : \" h \" " ));
}
fn record ( email : & str ) -> crate ::answers ::Answer {
crate ::answers ::Answer {
id : "abc" . into (),
question_id : "/report" . into (),
alternative : "Report a hazard" . into (),
responses : serde_json ::json! ({ "email" : email , "where" : "The bend" }),
state : "open" . into (),
submitted_ms : 1 ,
decided_ms : None ,
decided_by : None ,
notes : Vec ::new (),
}
}
fn reply ( from : & str , text : & str ) -> Incoming {
Incoming {
v : CONTRACT ,
id : "mail-1" . into (),
bucket : "hazards" . into (),
record : "abc" . into (),
from : from . into (),
subject : "Re: We have it" . into (),
text : text . into (),
received_ms : 2 ,
}
}
#[test]
fn a_reply_from_the_address_on_the_record_becomes_a_note () {
let note = note_for ( & reply ( "Kari <KARI@x.no>" , " It is fixed now. " ), & record ( "kari@x.no" )). unwrap ();
assert_eq! ( note . from , "kari@x.no" );
assert_eq! ( note . text , "It is fixed now." );
assert_eq! ( note . id , "mail-1" );
assert_eq! ( note . received_ms , 2 );
}
#[test]
fn a_reply_from_anyone_else_is_refused () {
// The address a reply arrives on is unguessable, but a From is
// a claim, and a mailbox is a door anyone may knock on.
let err = note_for ( & reply ( "someone@else.no" , "Let me in" ), & record ( "kari@x.no" )). unwrap_err ();
assert! ( err . contains ( "not the address this record was written to" ), "{err}" );
// Nothing to compare against is also a refusal, not a pass.
let mut anonymous = record ( "kari@x.no" );
anonymous . responses = serde_json ::json! ({ "where" : "The bend" });
assert! ( note_for ( & reply ( "kari@x.no" , "hi" ), & anonymous ). unwrap_err (). contains ( "no address" ));
}
#[test]
fn an_empty_reply_and_a_repeated_one_add_nothing () {
assert! ( note_for ( & reply ( "kari@x.no" , " \n " ), & record ( "kari@x.no" )). unwrap_err (). contains ( "no text" ));
let mut already = record ( "kari@x.no" );
already . notes . push ( note_for ( & reply ( "kari@x.no" , "It is fixed." ), & already ). unwrap ());
let err = note_for ( & reply ( "kari@x.no" , "It is fixed." ), & already ). unwrap_err ();
assert! ( err . contains ( "already on the record" ), "{err}" );
}
#[test]
fn a_reply_from_a_contract_this_portal_does_not_know_is_refused () {
let mut future = reply ( "kari@x.no" , "hello" );
future . v = CONTRACT + 1 ;
assert! ( note_for ( & future , & record ( "kari@x.no" )). unwrap_err (). contains ( "contract" ));
}
#[test]
fn an_address_is_read_out_of_whatever_the_mail_server_said () {
assert_eq! ( address_of ( "Kari Nordmann <Kari@X.no>" ), "kari@x.no" );
assert_eq! ( address_of ( " kari@x.no " ), "kari@x.no" );
assert_eq! ( address_of ( "<kari@x.no>" ), "kari@x.no" );
// A display name that looks like an address is not the address.
assert_eq! ( address_of ( " \" admin@bank.no \" <kari@x.no>" ), "kari@x.no" );
2026-09-23 19:45:48 +02:00
}
}