Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/srv/publish/send.rs

69.9 KiB, 174 runs

created by r1870400018:14765, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Sending a post to the remotes it is bound for.
2//!
3//! A [`Destination`](super::dest::Destination) names a remote; this module reaches it. Each sender
4//! builds a request, makes one HTTPS call through [`https_request`], reads the reply, and returns the
5//! permalink the remote gave back -- the address a backlink points at, and the proof the post landed.
6//!
7//! # Two that need no new machinery
8//!
9//! Mastodon takes a static bearer token and one POST. Bluesky takes an app password, exchanges it for
10//! a session, and posts with the session's token. Neither needs an OAuth client, which is why they are
11//! the first two wired: they exercise the whole delivery seam for free. Email waits on a subscriber
12//! list that does not exist yet; X and Threads wait on OAuth.
13//!
14//! # Built pure, wrapped thin
15//!
16//! The request bodies and the reply parsing are pure functions over strings, tested without a socket.
17//! The network wrappers around them are as thin as they can be, because what cannot be exercised in a
18//! test against a live remote is exactly what a test cannot catch. What *can* be pinned -- the JSON a
19//! remote is sent, the permalink pulled from what it returns -- is.
20//!
21//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
22//! Anthropic Claude
23
24use crate::srv::publish::{
25 Post,
26 PostState,
27 PublishConfig,
28 dest::{
29 Delivery,
30 DeliveryState,
31 Destination,
32 Rendition,
33 },
34 store,
35 subscribe,
36};
37
38use oxedyne_fe2o3_core::{
39 prelude::*,
40 rand::Rand,
41};
42use oxedyne_fe2o3_iop_crypto::enc::Encrypter;
43use oxedyne_fe2o3_iop_db::api::Database;
44use oxedyne_fe2o3_iop_hash::api::Hasher;
45use oxedyne_fe2o3_jdat::{
46 prelude::*,
47 id::NumIdDat,
48 string::dec::DecoderConfig,
49 usr::{
50 UsrKind,
51 UsrKindCode,
52 UsrKindId,
53 },
54};
55use oxedyne_fe2o3_datime::{
56 constant::DayOfWeek,
57 format::rfc9557::Rfc9557Format,
58 time::{
59 CalClock,
60 CalClockZone,
61 },
62};
63use oxedyne_fe2o3_net::{
64 dkim::DkimSigner,
65 http::{
66 client::https_request,
67 header::{
68 HttpHeadline,
69 HttpMethod,
70 },
71 msg::HttpMessage,
72 },
73 smtp::client::{
74 OutboundClient,
75 is_permanent,
76 },
77};
78use oxedyne_fe2o3_text::doc::html::{
79 escape_attr,
80 escape_text,
81};
82
83use std::{
84 collections::BTreeMap,
85 path::Path,
86 sync::{
87 Arc,
88 RwLock,
89 },
90 time::{
91 SystemTime,
92 UNIX_EPOCH,
93 },
94};
95
96use tokio_rustls::rustls::ClientConfig;
97
98
99// The default Bluesky host, where a site's config names none: the public PDS, which is what an app
100// password authenticates against unless a site runs its own.
101pub const BLUESKY_HOST_DEFAULT: &str = "bsky.social";
102
103
104#[derive(Clone, Debug, Default, Eq, PartialEq)]
105pub struct MastodonCreds {
106 pub base_url: String, // e.g. `https://mastodon.social`; scheme stripped to dial
107 pub token: String, // a static bearer, from a secret reference
108}
109
110#[derive(Clone, Debug, Default, Eq, PartialEq)]
111pub struct BlueskyCreds {
112 pub host: String, // e.g. `bsky.social`; defaults to BLUESKY_HOST_DEFAULT
113 pub handle: String, // e.g. `me.bsky.social`, which is the session identifier
114 // An app password, not the account password: Bluesky issues these precisely so a third party
115 // holds one and it can be revoked on its own. Resolved from a secret reference.
116 pub app_password: String,
117}
118
119/// The remotes a site is configured to post to.
120///
121/// A destination the site has not configured is one it will not offer and cannot send to, whatever its
122/// [`Capability`](super::dest::Capability) says in the abstract: a capability describes what a remote
123/// *could* take, this describes which remotes *this* site actually reaches.
124#[derive(Clone, Debug, Default, Eq, PartialEq)]
125pub struct DestCreds {
126 pub mastodon: Option<MastodonCreds>,
127 pub bluesky: Option<BlueskyCreds>,
128}
129
130impl DestCreds {
131
132 /// Whether the site has credentials for a destination, and so can offer it.
133 pub fn has(&self, dest: Destination) -> bool {
134 match dest {
135 Destination::Mastodon => self.mastodon.is_some(),
136 Destination::Bluesky => self.bluesky.is_some(),
137 // The rest are not wired regardless of config.
138 _ => false,
139 }
140 }
141
142 /// The destinations the site can offer, in a picker's order.
143 pub fn offered(&self) -> Vec<Destination> {
144 Destination::ALL.iter().copied().filter(|d| self.has(*d)).collect()
145 }
146
147 /// These credentials laid over `base`, taking precedence where both name a remote.
148 ///
149 /// How the interactively-entered credentials (in the store) win over the ones in the config file
150 /// while still falling back to config for a remote the console has not set. Per-remote, not
151 /// all-or-nothing: a site may keep Mastodon in its config and set Bluesky from the console.
152 pub fn overlay(self, base: DestCreds) -> DestCreds {
153 DestCreds {
154 mastodon: self.mastodon.or(base.mastodon),
155 bluesky: self.bluesky.or(base.bluesky),
156 }
157 }
158
159 pub fn to_dat(&self) -> Dat {
160 let mut m = DaticleMap::new();
161 if let Some(x) = &self.mastodon {
162 m.insert(dat!("mastodon"), x.to_dat());
163 }
164 if let Some(x) = &self.bluesky {
165 m.insert(dat!("bluesky"), x.to_dat());
166 }
167 Dat::Map(m)
168 }
169
170 /// The credentials from a daticle, leniently: a remote whose block will not read is dropped, not an
171 /// error, since a settings page and a delivery must both survive a record a later version wrote or a
172 /// half-written one. This is the store's reader; [`from_datmap`](Self::from_datmap) is the config's,
173 /// and errors, because a broken *config* is an operator's mistake to be told about.
174 pub fn from_dat(d: &Dat) -> Self {
175 let m = match d {
176 Dat::Map(m) => m,
177 _ => return Self::default(),
178 };
179 Self {
180 mastodon: m.get(&dat!("mastodon")).and_then(MastodonCreds::from_dat),
181 bluesky: m.get(&dat!("bluesky")).and_then(BlueskyCreds::from_dat),
182 }
183 }
184
185 /// Parses a vhost's `destinations` block: a map of per-remote credential blocks. A remote the site
186 /// has no block for is a remote it does not post to, and reads as `None` rather than an error.
187 pub fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
188 let mastodon = match m.get(&dat!("mastodon")) {
189 Some(Dat::Map(mm)) => Some(res!(MastodonCreds::from_datmap(mm))),
190 None => None,
191 _ => return Err(err!(
192 "publish: 'destinations.mastodon' must be a map.";
193 Invalid, Input, Mismatch)),
194 };
195 let bluesky = match m.get(&dat!("bluesky")) {
196 Some(Dat::Map(bm)) => Some(res!(BlueskyCreds::from_datmap(bm))),
197 None => None,
198 _ => return Err(err!(
199 "publish: 'destinations.bluesky' must be a map.";
200 Invalid, Input, Mismatch)),
201 };
202 Ok(Self { mastodon, bluesky })
203 }
204
205 /// Resolves every credential's `{env:}`/`{file:}` secret reference against the app root, so a token
206 /// is never in the config in the clear.
207 pub fn resolve_secrets(&mut self, root: &Path) -> Outcome<()> {
208 if let Some(m) = &mut self.mastodon {
209 res!(m.resolve_secrets(root));
210 }
211 if let Some(b) = &mut self.bluesky {
212 res!(b.resolve_secrets(root));
213 }
214 Ok(())
215 }
216}
217
218impl MastodonCreds {
219
220 /// Parses a `destinations.mastodon` block. Both fields are required where the block is present: an
221 /// account named without an instance or without a token is one no post can reach, and saying so at
222 /// load beats a delivery failing at send with the same cause.
223 fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
224 Ok(Self {
225 base_url: res!(cred_str(m, "base_url", "destinations.mastodon")),
226 token: res!(cred_str(m, "token", "destinations.mastodon")),
227 })
228 }
229
230 /// Resolves the token's secret reference. The instance URL is public and taken as written.
231 fn resolve_secrets(&mut self, root: &Path) -> Outcome<()> {
232 self.token = res!(crate::srv::cfg::ApiRoute::resolve_file_refs(&self.token, root));
233 Ok(())
234 }
235
236 fn to_dat(&self) -> Dat {
237 let mut m = DaticleMap::new();
238 m.insert(dat!("base_url"), dat!(self.base_url.clone()));
239 m.insert(dat!("token"), dat!(self.token.clone()));
240 Dat::Map(m)
241 }
242
243 /// The credentials from a stored daticle, or nothing where either required field is missing.
244 fn from_dat(d: &Dat) -> Option<Self> {
245 let m = ok!(as_map(d));
246 Some(Self {
247 base_url: ok!(nonempty(m, "base_url")),
248 token: ok!(nonempty(m, "token")),
249 })
250 }
251}
252
253impl BlueskyCreds {
254
255 /// Parses a `destinations.bluesky` block. Handle and app password are required; the host defaults to
256 /// [`BLUESKY_HOST_DEFAULT`], since most accounts live on the public PDS.
257 fn from_datmap(m: &DaticleMap) -> Outcome<Self> {
258 let host = match m.get(&dat!("host")) {
259 Some(Dat::Str(s)) if !s.trim().is_empty() => s.trim().to_string(),
260 _ => BLUESKY_HOST_DEFAULT.to_string(),
261 };
262 Ok(Self {
263 host,
264 handle: res!(cred_str(m, "handle", "destinations.bluesky")),
265 app_password: res!(cred_str(m, "app_password", "destinations.bluesky")),
266 })
267 }
268
269 /// Resolves the app password's secret reference. The handle and host are public and taken as
270 /// written.
271 fn resolve_secrets(&mut self, root: &Path) -> Outcome<()> {
272 self.app_password = res!(crate::srv::cfg::ApiRoute::resolve_file_refs(&self.app_password, root));
273 Ok(())
274 }
275
276 fn to_dat(&self) -> Dat {
277 let mut m = DaticleMap::new();
278 m.insert(dat!("host"), dat!(self.host.clone()));
279 m.insert(dat!("handle"), dat!(self.handle.clone()));
280 m.insert(dat!("app_password"), dat!(self.app_password.clone()));
281 Dat::Map(m)
282 }
283
284 /// The credentials from a stored daticle, or nothing where the handle or password is missing. The
285 /// host defaults, as it does from config.
286 fn from_dat(d: &Dat) -> Option<Self> {
287 let m = ok!(as_map(d));
288 let host = nonempty(m, "host").unwrap_or_else(|| BLUESKY_HOST_DEFAULT.to_string());
289 Some(Self {
290 host,
291 handle: ok!(nonempty(m, "handle")),
292 app_password: ok!(nonempty(m, "app_password")),
293 })
294 }
295}
296
297fn as_map(d: &Dat) -> Option<&DaticleMap> {
298 match d {
299 Dat::Map(m) => Some(m),
300 _ => None,
301 }
302}
303
304fn nonempty(m: &DaticleMap, key: &str) -> Option<String> {
305 match m.get(&dat!(key)) {
306 Some(Dat::Str(s)) if !s.trim().is_empty() => Some(s.clone()),
307 _ => None,
308 }
309}
310
311
312pub const CREDS_KEY: &str = "publish/creds";
313
314/// The credentials a site has set from the console, from its store. An empty set where none are stored,
315/// which is not an error: a site sets its remotes from the console or its config or neither.
316pub fn get_creds<
317 const UIDL: usize,
318 UID: NumIdDat<UIDL>,
319 ENC: Encrypter,
320 KH: Hasher,
321 DB: Database<UIDL, UID, ENC, KH>,
322>(
323 db: &(Arc<RwLock<DB>>, UID),
324)
325 -> Outcome<DestCreds>
326{
327 let (db_arc, _) = db;
328 let guard = lock_read!(db_arc);
329 match res!(guard.get(&dat!(CREDS_KEY), None)) {
330 Some((val, _)) => Ok(DestCreds::from_dat(&val)),
331 None => Ok(DestCreds::default()),
332 }
333}
334
335/// Writes a site's console-set credentials to its store, where they are encrypted at rest under the
336/// database's own scheme -- the same treatment its posts, sessions and users get, and the reason a
337/// token entered here is not a token in a file in the clear.
338pub fn put_creds<
339 const UIDL: usize,
340 UID: NumIdDat<UIDL>,
341 ENC: Encrypter,
342 KH: Hasher,
343 DB: Database<UIDL, UID, ENC, KH>,
344>(
345 db: &(Arc<RwLock<DB>>, UID),
346 creds: &DestCreds,
347)
348 -> Outcome<()>
349{
350 let (db_arc, user) = db;
351 let guard = lock_read!(db_arc);
352 res!(guard.insert(dat!(CREDS_KEY), creds.to_dat(), *user, None));
353 Ok(())
354}
355
356/// The credentials that actually apply: what the console has set, laid over what the config names, so a
357/// remote set interactively wins and one left to the config still works. This is what the picker offers
358/// and what a delivery is sent with.
359pub fn effective_creds<
360 const UIDL: usize,
361 UID: NumIdDat<UIDL>,
362 ENC: Encrypter,
363 KH: Hasher,
364 DB: Database<UIDL, UID, ENC, KH>,
365>(
366 db: &(Arc<RwLock<DB>>, UID),
367 cfg: &crate::srv::publish::PublishConfig,
368)
369 -> Outcome<DestCreds>
370{
371 Ok(res!(get_creds(db)).overlay(cfg.creds.clone()))
372}
373
374/// A required string field of a credential block, named for the block it is missing from.
375fn cred_str(m: &DaticleMap, key: &str, block: &str) -> Outcome<String> {
376 match m.get(&dat!(key)) {
377 Some(Dat::Str(s)) if !s.trim().is_empty() => Ok(s.clone()),
378 Some(Dat::Str(_)) | None => Err(err!(
379 "publish: '{}.{}' is required and must be a non-empty string.", block, key;
380 Invalid, Input, Missing)),
381 _ => Err(err!(
382 "publish: '{}.{}' must be a string.", block, key;
383 Invalid, Input, Mismatch)),
384 }
385}
386
387
388/// Sends the rendition's words to a destination and returns the permalink the remote gave back.
389///
390/// The one door every send goes through. A destination the site has no credentials for, or one no
391/// sender is written for, is an error and not a silent success: a delivery that reports itself sent
392/// when nothing left the building is worse than one that fails honestly.
393pub async fn deliver_one(
394 dest: Destination,
395 creds: &DestCreds,
396 text: &str,
397 tls: Arc<ClientConfig>,
398)
399 -> Outcome<String>
400{
401 match dest {
402 Destination::Mastodon => match &creds.mastodon {
403 Some(c) => mastodon(c, text, tls).await,
404 None => Err(err!(
405 "publish: this site has no Mastodon credentials configured.";
406 Input, Missing)),
407 },
408 Destination::Bluesky => match &creds.bluesky {
409 Some(c) => bluesky(c, text, tls).await,
410 None => Err(err!(
411 "publish: this site has no Bluesky credentials configured.";
412 Input, Missing)),
413 },
414 other => Err(err!(
415 "publish: no sender is wired for {}.", other.as_str();
416 Input, Unknown)),
417 }
418}
419
420
421/// Attempts every delivery of a post that is not yet done, and writes back what happened.
422///
423/// The outbox in one pass. It reads the post, walks its deliveries, and for each one still
424/// [`open`](is_open) -- queued, or failed but not past retrying -- sends the rendition and records the
425/// outcome: a permalink and the moment on success, the error and a bumped retry count on failure. The
426/// record is written back once, at the end, with all the outcomes on it.
427///
428/// # Held under no lock
429///
430/// A network is slow and a database lock is not for holding across one. So the post is read, released,
431/// sent over the wire, and only then written back. Two saves racing settle last-write-wins, which for a
432/// delivery log is a cost worth its simplicity: the worst case re-sends a post, and the backfeed shows
433/// it, where holding a lock across a remote that has stopped answering would wedge the site.
434///
435/// Returns how many deliveries were attempted -- zero where the post has none open, which is the
436/// ordinary case for a post already sent everywhere it goes.
437pub async fn deliver_post<
438 const UIDL: usize,
439 UID: NumIdDat<UIDL>,
440 ENC: Encrypter,
441 KH: Hasher,
442 DB: Database<UIDL, UID, ENC, KH>,
443>(
444 db: &(Arc<RwLock<DB>>, UID),
445 creds: &DestCreds,
446 tls: Arc<ClientConfig>,
447 slug: &str,
448 id: &str,
449)
450 -> Outcome<usize>
451{
452 let mut rec = match res!(store::get(db, slug)) {
453 Some(r) => r,
454 // A post that is not there has no deliveries to make, and is not an error: it may have been
455 // deleted between the queueing and the sweep.
456 None => return Ok(0),
457 };
458
459 let mut attempted = 0;
460 for delivery in &mut rec.deliveries {
461 if !is_open(&delivery.state) {
462 continue;
463 }
464 attempted += 1;
465 let retries = match &delivery.state {
466 DeliveryState::Failed { retries, .. } => *retries,
467 _ => 0,
468 };
469 match deliver_one(delivery.dest, creds, &delivery.rendition.text, tls.clone()).await {
470 Ok(permalink) => {
471 let at = res!(iso_now());
472 info!("{}: publish: '{}' delivered to {} at {}",
473 id, slug, delivery.dest.as_str(), permalink);
474 delivery.state = DeliveryState::Sent { at, permalink };
475 }
476 Err(e) => {
477 let at = iso_now().unwrap_or_default();
478 warn!("{}: publish: '{}' to {} failed (attempt {}): {}",
479 id, slug, delivery.dest.as_str(), retries + 1, e);
480 delivery.state = DeliveryState::Failed {
481 at,
482 err: fmt!("{}", e),
483 retries: retries + 1,
484 };
485 }
486 }
487 }
488
489 if attempted > 0 {
490 res!(store::put(db, &rec, id));
491 }
492 Ok(attempted)
493}
494
495/// Whether a delivery still wants an attempt: queued, or failed but not yet past retrying. The
496/// complement of [`DeliveryState::is_terminal`], named for the loop that reads it.
497fn is_open(state: &DeliveryState) -> bool {
498 !state.is_terminal()
499}
500
501/// Sets a post's deliveries to a fresh queue for the destinations named, keeping a hand-edited
502/// rendition where one already exists for a destination and deriving a default where none does.
503///
504/// What the composer calls when an author ticks destinations and saves. It does not send -- it queues;
505/// [`deliver_post`] sends. A destination dropped from the set loses its delivery, and one already sent
506/// keeps it, so re-saving does not re-send what has gone: only an open or new delivery is queued.
507pub fn queue_deliveries(
508 existing: &[Delivery],
509 chosen: &[Destination],
510 title: &str,
511 url: &str,
512) -> Vec<Delivery> {
513 let mut out = Vec::new();
514 for &dest in chosen {
515 let prior = existing.iter().find(|d| d.dest == dest);
516 match prior {
517 // Already sent, or already carrying a hand-written rendition: keep it as it stands. A
518 // re-save must not re-derive over an edit, nor re-open a delivery that has landed.
519 Some(d) if matches!(d.state, DeliveryState::Sent { .. }) || !d.rendition.auto => {
520 out.push(d.clone());
521 }
522 // Known but still open with an automatic rendition: refresh the rendition (the title or link
523 // may have changed) and leave it queued.
524 Some(_) => {
525 let rendition = Rendition::promo(title, url, dest.capability().max_chars);
526 out.push(Delivery::new(dest, rendition));
527 }
528 // New: derive a default and queue it.
529 None => {
530 let rendition = Rendition::promo(title, url, dest.capability().max_chars);
531 out.push(Delivery::new(dest, rendition));
532 }
533 }
534 }
535 out
536}
537
538
539// ┌───────────────────────────────────────────────────────────────────────────┐
540// │ MASTODON │
541// └───────────────────────────────────────────────────────────────────────────┘
542
543/// Posts to Mastodon and returns the status's URL.
544///
545/// One POST to `/api/v1/statuses`, a JSON body carrying the text, the token as a bearer. The reply is
546/// the created status, and its `url` is the permalink.
547pub async fn mastodon(
548 creds: &MastodonCreds,
549 text: &str,
550 tls: Arc<ClientConfig>,
551)
552 -> Outcome<String>
553{
554 let host = host_of(&creds.base_url);
555 let body = res!(mastodon_status_body(text));
556 let auth = fmt!("Bearer {}", creds.token);
557 let headers = [
558 ("Authorization", auth.as_str()),
559 ("Content-Type", "application/json"),
560 ("Accept", "application/json"),
561 ];
562 let resp = res!(https_request(
563 &host, 443, HttpMethod::POST, "/api/v1/statuses", &headers, body.as_bytes(), tls,
564 ).await);
565 let code = status_code(&resp);
566 let payload = resp.body_as_string().into_owned();
567 if !is_success(code) {
568 return Err(err!(
569 "Mastodon at {} refused the post with {}: {}", host, code, payload;
570 Network, Data));
571 }
572 let m = res!(parse_json(&payload));
573 match json_str(&m, "url") {
574 Some(u) => Ok(u),
575 None => Err(err!(
576 "Mastodon accepted the post but returned no url: {}", payload;
577 Network, Data, Missing)),
578 }
579}
580
581/// The JSON body for a Mastodon status: `{"status": "<text>"}`.
582fn mastodon_status_body(text: &str) -> Outcome<String> {
583 let mut m = DaticleMap::new();
584 m.insert(dat!("status"), dat!(text.to_string()));
585 Dat::Map(m).json()
586}
587
588
589// ┌───────────────────────────────────────────────────────────────────────────┐
590// │ BLUESKY │
591// └───────────────────────────────────────────────────────────────────────────┘
592
593/// Posts to Bluesky and returns a link to the post.
594///
595/// Two calls: `createSession` exchanges the app password for a session token and the account's DID,
596/// then `createRecord` writes the post under that DID. The reply is an `at://` URI, which
597/// [`at_uri_to_url`] turns into the `bsky.app` address a person can open.
598pub async fn bluesky(
599 creds: &BlueskyCreds,
600 text: &str,
601 tls: Arc<ClientConfig>,
602)
603 -> Outcome<String>
604{
605 let host = if creds.host.trim().is_empty() {
606 BLUESKY_HOST_DEFAULT.to_string()
607 } else {
608 creds.host.trim().to_string()
609 };
610
611 // 1. Exchange the app password for a session.
612 let sess_body = res!(bluesky_session_body(&creds.handle, &creds.app_password));
613 let headers = [
614 ("Content-Type", "application/json"),
615 ("Accept", "application/json"),
616 ];
617 let resp = res!(https_request(
618 &host, 443, HttpMethod::POST, "/xrpc/com.atproto.server.createSession",
619 &headers, sess_body.as_bytes(), tls.clone(),
620 ).await);
621 let code = status_code(&resp);
622 let payload = resp.body_as_string().into_owned();
623 if !is_success(code) {
624 return Err(err!(
625 "Bluesky at {} refused the session with {}: {}", host, code, payload;
626 Network, Data));
627 }
628 let sm = res!(parse_json(&payload));
629 let jwt = match json_str(&sm, "accessJwt") {
630 Some(j) => j,
631 None => return Err(err!(
632 "Bluesky opened a session but returned no accessJwt: {}", payload;
633 Network, Data, Missing)),
634 };
635 let did = match json_str(&sm, "did") {
636 Some(d) => d,
637 None => return Err(err!(
638 "Bluesky opened a session but returned no did: {}", payload;
639 Network, Data, Missing)),
640 };
641
642 // 2. Write the post under the account's DID.
643 let created = res!(iso_now());
644 let rec_body = res!(bluesky_record_body(&did, text, &created));
645 let auth = fmt!("Bearer {}", jwt);
646 let headers2 = [
647 ("Authorization", auth.as_str()),
648 ("Content-Type", "application/json"),
649 ("Accept", "application/json"),
650 ];
651 let resp2 = res!(https_request(
652 &host, 443, HttpMethod::POST, "/xrpc/com.atproto.repo.createRecord",
653 &headers2, rec_body.as_bytes(), tls,
654 ).await);
655 let code2 = status_code(&resp2);
656 let payload2 = resp2.body_as_string().into_owned();
657 if !is_success(code2) {
658 return Err(err!(
659 "Bluesky at {} refused the post with {}: {}", host, code2, payload2;
660 Network, Data));
661 }
662 let rm = res!(parse_json(&payload2));
663 let uri = match json_str(&rm, "uri") {
664 Some(u) => u,
665 None => return Err(err!(
666 "Bluesky accepted the post but returned no uri: {}", payload2;
667 Network, Data, Missing)),
668 };
669 match at_uri_to_url(&uri) {
670 Some(url) => Ok(url),
671 // The post is up, but its address is not the shape this understands. Better to say so and keep
672 // the at-uri than to claim a bsky.app link that may not resolve.
673 None => Ok(uri),
674 }
675}
676
677/// The JSON body for `createSession`: `{"identifier": "<handle>", "password": "<app password>"}`.
678fn bluesky_session_body(handle: &str, app_password: &str) -> Outcome<String> {
679 let mut m = DaticleMap::new();
680 m.insert(dat!("identifier"), dat!(handle.to_string()));
681 m.insert(dat!("password"), dat!(app_password.to_string()));
682 Dat::Map(m).json()
683}
684
685/// The JSON body for `createRecord`: a feed post under the account's repo, stamped with the moment it
686/// was written, which Bluesky requires and orders timelines by.
687fn bluesky_record_body(did: &str, text: &str, created_at: &str) -> Outcome<String> {
688 let mut record = DaticleMap::new();
689 record.insert(dat!("$type"), dat!("app.bsky.feed.post".to_string()));
690 record.insert(dat!("text"), dat!(text.to_string()));
691 record.insert(dat!("createdAt"), dat!(created_at.to_string()));
692
693 let mut outer = DaticleMap::new();
694 outer.insert(dat!("repo"), dat!(did.to_string()));
695 outer.insert(dat!("collection"), dat!("app.bsky.feed.post".to_string()));
696 outer.insert(dat!("record"), Dat::Map(record));
697 Dat::Map(outer).json()
698}
699
700/// A `bsky.app` link from the `at://` URI a write returns.
701///
702/// `at://<did>/app.bsky.feed.post/<rkey>` becomes
703/// `https://bsky.app/profile/<did>/post/<rkey>`, which is the address a person opens. A URI that is
704/// not that shape yields nothing, and the caller keeps the URI rather than inventing a link.
705fn at_uri_to_url(uri: &str) -> Option<String> {
706 let rest = ok!(uri.strip_prefix("at://"));
707 let mut parts = rest.splitn(3, '/');
708 let did = ok!(parts.next());
709 let _collection = ok!(parts.next());
710 let rkey = ok!(parts.next());
711 if did.is_empty() || rkey.is_empty() {
712 return None;
713 }
714 Some(fmt!("https://bsky.app/profile/{}/post/{}", did, rkey))
715}
716
717
718// ┌───────────────────────────────────────────────────────────────────────────┐
719// │ SHARED HELPERS │
720// └───────────────────────────────────────────────────────────────────────────┘
721
722/// The host a base URL names, without its scheme or a trailing slash, for dialling.
723fn host_of(base_url: &str) -> String {
724 let s = base_url.trim();
725 let s = s.strip_prefix("https://")
726 .or_else(|| s.strip_prefix("http://"))
727 .unwrap_or(s);
728 s.trim_end_matches('/').to_string()
729}
730
731/// The numeric status of a response, or zero where the message is somehow not a response.
732fn status_code(msg: &HttpMessage) -> u16 {
733 match &msg.header.headline {
734 HttpHeadline::Response { status } => *status as u16,
735 _ => 0,
736 }
737}
738
739/// Whether a status is a 2xx.
740fn is_success(code: u16) -> bool {
741 (200..300).contains(&code)
742}
743
744fn parse_json(text: &str) -> Outcome<DaticleMap> {
745 let cfg: DecoderConfig<
746 BTreeMap<UsrKindCode, UsrKind>,
747 BTreeMap<String, UsrKindId>,
748 > = DecoderConfig::json(None);
749 let dat = res!(Dat::decode_string_with_config(text.to_string(), &cfg));
750 match dat {
751 Dat::Map(m) => Ok(m),
752 other => Err(err!(
753 "expected a JSON object, got {:?}", other.kind();
754 Input, Decode, Mismatch)),
755 }
756}
757
758fn json_str(m: &DaticleMap, key: &str) -> Option<String> {
759 match m.get(&dat!(key)) {
760 Some(Dat::Str(s)) => Some(s.clone()),
761 _ => None,
762 }
763}
764
765/// The current moment as an RFC 3339 timestamp in UTC.
766///
767/// Unlike a post's date -- a day, which needs no clock and no calendar (the reason the feed is Atom) --
768/// a delivery is an instant on a network, and the remote wants it stamped. So here the module does
769/// reach for a clock and for [`CalClock`], which is the fe2o3 calendar and the right tool for turning
770/// a unix second into a date a remote will accept.
771pub fn iso_now() -> Outcome<String> {
772 let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
773 Ok(d) => d.as_secs() as i64,
774 Err(_) => 0,
775 };
776 iso_of(secs)
777}
778
779/// A unix second as an RFC 3339 timestamp in UTC.
780pub fn iso_of(unix_secs: i64) -> Outcome<String> {
781 let cc = res!(CalClock::from_unix_timestamp_seconds(unix_secs, CalClockZone::utc()));
782 cc.to_rfc9557_basic()
783}
784
785
786// ┌───────────────────────────────────────────────────────────────────────────┐
787// │ EMAIL: the site's own DKIM sender │
788// └───────────────────────────────────────────────────────────────────────────┘
789
790/// The site's own outbound mail: the SMTP client that reaches a recipient's MX, and the DKIM
791/// identities that sign what it sends.
792///
793/// "Own the send." Built once at start-up from the server's mail configuration and threaded into the
794/// publish path the way the outbound TLS client is, so the newsletter and its confirmation are signed
795/// and delivered by exactly the machinery the mail server and the operator alerter already use --
796/// [`OutboundClient::deliver`] straight to the recipient's MX, each message signed by every configured
797/// [`DkimSigner`] as [`crate::srv::alert`] and the mail handler both do it.
798///
799/// Cheap to clone: the client and the signers are shared behind `Arc`s.
800#[derive(Clone)]
801pub struct MailSender {
802 client: Arc<OutboundClient>, // dials each recipient's MX directly
803 // The DKIM identities every message is signed with. Empty means unsigned, which still delivers
804 // -- a key that will not sign is skipped, not fatal, as everywhere else the domain signs its
805 // mail.
806 dkim: Vec<Arc<DkimSigner>>,
807 // The address the newsletter is from where a site's `publish` block names none, derived from
808 // the mail configuration's signing domain, e.g. `news@<domain>`.
809 default_from: String,
810}
811
812impl std::fmt::Debug for MailSender {
813 /// Written by hand because [`OutboundClient`] is not `Debug`; the sending identity is the part worth
814 /// a log line.
815 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
816 f.debug_struct("MailSender")
817 .field("default_from", &self.default_from)
818 .field("dkim", &self.dkim.len())
819 .finish()
820 }
821}
822
823impl MailSender {
824
825 /// Builds a sender from an EHLO hostname, the DKIM identities to sign with, and the default
826 /// newsletter From address.
827 ///
828 /// The signers and the From are the caller's -- built from the server's mail configuration -- so this
829 /// invents no key and reads no config: it is the same pattern the alerter follows.
830 pub fn new(
831 ehlo_host: String,
832 dkim: Vec<Arc<DkimSigner>>,
833 default_from: String,
834 )
835 -> Outcome<Self>
836 {
837 let client = res!(OutboundClient::with_system_roots(ehlo_host));
838 Ok(Self {
839 client: Arc::new(client),
840 dkim,
841 default_from,
842 })
843 }
844
845 pub fn default_from(&self) -> &str {
846 &self.default_from
847 }
848
849 /// Signs a message with every configured DKIM identity and delivers it to one recipient's MX.
850 ///
851 /// The one door every piece of newsletter mail goes through, the confirmation included. Each signer
852 /// prepends its own `DKIM-Signature`; a key that will not sign is skipped with a warning rather than
853 /// failing the send, since an unsigned message that arrives beats a signed one that does not.
854 /// Returns the remote's queue id.
855 async fn deliver_signed(
856 &self,
857 from: &str,
858 to: &str,
859 msg: &str,
860 )
861 -> Outcome<String>
862 {
863 let mut bytes = msg.as_bytes().to_vec();
864 let now = match SystemTime::now().duration_since(UNIX_EPOCH) {
865 Ok(d) => d.as_secs(),
866 Err(_) => 0,
867 };
868 // Signed as the domain the message says it is from, so the signature is aligned and the
869 // receiver can read it as evidence about this sender.
870 for signer in &self.signers_for(domain_of(envelope_of(from))) {
871 match signer.sign(&bytes, &[], now) {
872 Ok(b) => bytes = b,
873 Err(e) => warn!("publish: signing newsletter mail with the {} key for selector \
874 '{}' failed; sending it unsigned: {}",
875 signer.algorithm(), signer.selector(), e),
876 }
877 }
878 let rcpt = [to.to_string()];
879 // The envelope takes the address alone. The header keeps the display name, which is what a
880 // reader sees; the reverse-path may not carry one, and a server handed
881 // `MAIL FROM:<README <news@example.com>>` refuses it with a 5xx -- which this module reads as
882 // a permanent failure and suppresses the subscriber for good. So the documented shape of
883 // `newsletter_from` would have quietly bounced every address it was ever used with.
884 self.client.deliver(envelope_of(from), &rcpt, &bytes).await
885 }
886
887 /// The signers this message should carry, given the domain its From speaks for.
888 ///
889 /// A signature is only worth something to the receiver if its `d=` matches the From address:
890 /// that match is what lets DMARC treat the signature as evidence about *this* sender rather
891 /// than about whoever runs the machine. A host serving several domains therefore signs each
892 /// one's mail as itself, from the same key, published under each domain.
893 ///
894 /// Where the caller names no domain, or names the one the signers already carry, the signers
895 /// are used as they are. A key that will not re-derive is passed over with a warning rather
896 /// than failing the send: an unaligned signature still beats no message.
897 fn signers_for(&self, domain: &str) -> Vec<Arc<DkimSigner>> {
898 if domain.is_empty() {
899 return self.dkim.clone();
900 }
901 self.dkim.iter().map(|s| {
902 if s.domain() == domain {
903 s.clone()
904 } else {
905 match s.for_domain(domain) {
906 Ok(d) => Arc::new(d),
907 Err(e) => {
908 warn!("publish: cannot sign as {} with the {} key for selector '{}'; \
909 signing as {} instead: {}",
910 domain, s.algorithm(), s.selector(), s.domain(), e);
911 s.clone()
912 }
913 }
914 }
915 }).collect()
916 }
917
918 /// Sends the double opt-in confirmation to a pending subscriber.
919 ///
920 /// One plain-text message carrying the confirm link and nothing else: it is not the newsletter, and
921 /// an address that never asked for it should get as little as possible.
922 pub async fn send_confirmation(
923 &self,
924 from: &str,
925 to: &str,
926 confirm_url: &str,
927 site_name: &str,
928 )
929 -> Outcome<String>
930 {
931 let msg = build_confirmation_email(from, to, confirm_url, site_name);
932 self.deliver_signed(from, to, &msg).await
933 }
934
935 /// Tells an operator that a comment is waiting for a person.
936 ///
937 /// Carries no comment text, on purpose. What is waiting is unmoderated, and forwarding a spam or an
938 /// abuse into an operator's inbox is doing the spammer's delivery for them; the message says only
939 /// that one is held and on which post, so the operator opens the queue where they can see it in
940 /// context and act on it. One plain line, auto-generated, from the site's own address.
941 pub async fn send_moderation_alert(
942 &self,
943 from: &str,
944 to: &str,
945 site_name: &str,
946 post_slug: &str,
947 )
948 -> Outcome<String>
949 {
950 let msg = build_moderation_alert_email(from, to, site_name, post_slug);
951 self.deliver_signed(from, to, &msg).await
952 }
953}
954
955/// The From address a newsletter is sent with, the site's own where it names one and the sender's
956/// derived default otherwise.
957///
958/// A method on the config so the resolution lives in one place: the `publish` block's
959/// `newsletter_from` wins, and an empty one falls back to `news@<mail-domain>`, which is aligned with
960/// the DKIM signing domain so the signature authenticates.
961impl PublishConfig {
962 pub fn newsletter_from(&self, sender: &MailSender) -> String {
963 if self.newsletter_from.trim().is_empty() {
964 sender.default_from().to_string()
965 } else {
966 self.newsletter_from.clone()
967 }
968 }
969}
970
971/// What one newsletter send did: how many it was sent to, and how each attempt ended.
972///
973/// The tally a send returns and a [`SendEntry`] is built from. `attempted` is the confirmed send set at
974/// the moment of the send; `sent` the deliveries the receiving server accepted; `failed` the transient
975/// failures, which stay on the list to try again; `suppressed` the permanent ones, whose addresses were
976/// marked [`SubState::Bounced`](super::subscribe::SubState::Bounced) and will not be sent to again.
977#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
978pub struct SendReport {
979 pub attempted: usize, // confirmed subscribers in the send set
980 pub sent: usize, // accepted by the receiving server
981 pub failed: usize, // transient failures, staying on the list to retry
982 pub suppressed: usize, // permanent failures, suppressed as bounced
983}
984
985/// Sends a live post to every confirmed subscriber, best-effort, one message each.
986///
987/// The [`Destination::Email`](super::dest::Destination::Email) delivery, but not through the per-remote
988/// retry queue: a newsletter is a fan-out to many addresses with no single permalink to return, so it
989/// is its own path rather than a [`Delivery`] on the post. Each subscriber gets a message carrying
990/// their own unsubscribe link -- built from their token, so the person who clicks it removes themselves
991/// and nobody else -- and delivery is per recipient, since [`OutboundClient::deliver`] is one MX per
992/// call. A recipient the send fails for is logged (redacted) and counted; the send does not stop.
993///
994/// A **permanent** failure -- a 5xx, an unknown mailbox, told apart by [`is_permanent`] -- suppresses
995/// the address: it is marked [`SubState::Bounced`](super::subscribe::SubState::Bounced) so no later send
996/// reaches it. A **transient** failure is merely counted, and the address stays confirmed for the next
997/// send. Returns the [`SendReport`] the caller records as history.
998pub async fn send_newsletter<
999 const UIDL: usize,
1000 UID: NumIdDat<UIDL>,
1001 ENC: Encrypter,
1002 KH: Hasher,
1003 DB: Database<UIDL, UID, ENC, KH>,
1004>(
1005 sender: &MailSender,
1006 db: &(Arc<RwLock<DB>>, UID),
1007 cfg: &PublishConfig,
1008 from: &str,
1009 slug: &str,
1010 id: &str,
1011)
1012 -> Outcome<SendReport>
1013{
1014 let rec = match res!(store::get(db, slug)) {
1015 Some(r) => r,
1016 None => return Err(err!(
1017 "publish: there is no post '{}' to send to subscribers.", slug;
1018 Invalid, Input, Missing)),
1019 };
1020 // A draft is served to nobody, and mailed to nobody: a newsletter is a publication, and a post not
1021 // published is not one.
1022 if rec.state != PostState::Live {
1023 return Err(err!(
1024 "publish: '{}' is a draft; a draft is sent to no subscriber.", slug;
1025 Invalid, Input));
1026 }
1027 let post = res!(rec.render());
1028 let subs = res!(subscribe::confirmed(db, id));
1029 let online = cfg.url_of(&cfg.path_of(slug));
1030
1031 let mut report = SendReport { attempted: subs.len(), ..Default::default() };
1032 for sub in &subs {
1033 let unsub = cfg.url_of(&cfg.unsubscribe_path(&sub.token));
1034 let msg = build_newsletter_email(from, &sub.email, &post, &online, &unsub, &cfg.site_name);
1035 match sender.deliver_signed(from, &sub.email, &msg).await {
1036 Ok(qid) => {
1037 report.sent += 1;
1038 debug!("{}: publish: newsletter '{}' to {} ({})",
1039 id, slug, subscribe::redact(&sub.email), qid);
1040 }
1041 // A permanent failure suppresses the address so no future send reaches it; a transient one is
1042 // counted and the address stays confirmed. The suppression is best-effort: if the mark itself
1043 // will not write, the send still finishes and logs, rather than fail the whole run.
1044 Err(e) if is_permanent(&e) => {
1045 report.suppressed += 1;
1046 warn!("{}: publish: newsletter '{}' to {} failed permanently; suppressing: {}",
1047 id, slug, subscribe::redact(&sub.email), e);
1048 if let Err(e2) = subscribe::mark_bounced(db, &sub.email, id) {
1049 warn!("{}: publish: could not suppress {}: {}",
1050 id, subscribe::redact(&sub.email), e2);
1051 }
1052 }
1053 Err(e) => {
1054 report.failed += 1;
1055 warn!("{}: publish: newsletter '{}' to {} failed: {}",
1056 id, slug, subscribe::redact(&sub.email), e);
1057 }
1058 }
1059 }
1060 info!("{}: publish: newsletter '{}' sent to {} of {} confirmed subscriber(s), {} failed, {} suppressed",
1061 id, slug, report.sent, report.attempted, report.failed, report.suppressed);
1062 Ok(report)
1063}
1064
1065/// Sends a live post to one address only: the operator's own, to see what a subscriber would get.
1066///
1067/// A test, not a send: it touches no subscriber state, marks nothing bounced whatever the delivery does,
1068/// and writes no history. The one recipient need not be a subscriber, so the unsubscribe link carries a
1069/// throwaway token that matches nobody -- the message is well-formed and its link is harmless. The build
1070/// is [`send_newsletter`]'s own, so the test is the newsletter, not an approximation of it.
1071pub async fn send_test<
1072 const UIDL: usize,
1073 UID: NumIdDat<UIDL>,
1074 ENC: Encrypter,
1075 KH: Hasher,
1076 DB: Database<UIDL, UID, ENC, KH>,
1077>(
1078 sender: &MailSender,
1079 db: &(Arc<RwLock<DB>>, UID),
1080 cfg: &PublishConfig,
1081 from: &str,
1082 slug: &str,
1083 to: &str,
1084 id: &str,
1085)
1086 -> Outcome<()>
1087{
1088 let to = subscribe::normalise_email(to);
1089 if !subscribe::valid_email(&to) {
1090 return Err(err!(
1091 "publish: {} is not a shape an address takes.", subscribe::redact(&to);
1092 Invalid, Input));
1093 }
1094 let rec = match res!(store::get(db, slug)) {
1095 Some(r) => r,
1096 None => return Err(err!(
1097 "publish: there is no post '{}' to test-send.", slug;
1098 Invalid, Input, Missing)),
1099 };
1100 if rec.state != PostState::Live {
1101 return Err(err!(
1102 "publish: '{}' is a draft; a test sends the live post a subscriber would get.", slug;
1103 Invalid, Input));
1104 }
1105 let post = res!(rec.render());
1106 let online = cfg.url_of(&cfg.path_of(slug));
1107 // A throwaway token: the link is well-formed but names no subscriber, so a test recipient who follows
1108 // it lands on the bad-token page and nobody is unsubscribed.
1109 let unsub = cfg.url_of(&cfg.unsubscribe_path(&subscribe::mint_token()));
1110 let msg = build_newsletter_email(from, &to, &post, &online, &unsub, &cfg.site_name);
1111 let qid = res!(sender.deliver_signed(from, &to, &msg).await);
1112 info!("{}: publish: test of '{}' sent to {} ({})", id, slug, subscribe::redact(&to), qid);
1113 Ok(())
1114}
1115
1116
1117// ┌───────────────────────────────────────────────────────────────────────────┐
1118// │ SEND HISTORY │
1119// └───────────────────────────────────────────────────────────────────────────┘
1120
1121pub const SENDS_KEY: &str = "publish/sends";
1122
1123/// One newsletter send, as the history keeps it.
1124///
1125/// A record of a send that happened: which post, when, and how the attempts ended. Written once per real
1126/// send -- never for a test -- and only appended to, so the history is the site's own log of what it
1127/// mailed and to how many.
1128#[derive(Clone, Debug, Default, Eq, PartialEq)]
1129pub struct SendEntry {
1130 pub slug: String, // the post that was sent
1131 pub at: String, // ISO timestamp
1132 pub attempted: usize, // confirmed subscribers in the send set
1133 pub sent: usize, // accepted by the receiving servers
1134 pub failed: usize, // transient failures
1135 pub suppressed: usize, // permanent failures, suppressed
1136}
1137
1138impl SendEntry {
1139
1140 pub fn of(slug: &str, at: &str, report: &SendReport) -> Self {
1141 Self {
1142 slug: slug.to_string(),
1143 at: at.to_string(),
1144 attempted: report.attempted,
1145 sent: report.sent,
1146 failed: report.failed,
1147 suppressed: report.suppressed,
1148 }
1149 }
1150
1151 pub fn to_dat(&self) -> Dat {
1152 let mut m = DaticleMap::new();
1153 m.insert(dat!("slug"), dat!(self.slug.clone()));
1154 m.insert(dat!("at"), dat!(self.at.clone()));
1155 m.insert(dat!("attempted"), Dat::U64(self.attempted as u64));
1156 m.insert(dat!("sent"), Dat::U64(self.sent as u64));
1157 m.insert(dat!("failed"), Dat::U64(self.failed as u64));
1158 m.insert(dat!("suppressed"), Dat::U64(self.suppressed as u64));
1159 Dat::Map(m)
1160 }
1161
1162 /// The entry from a daticle, leniently: a missing count reads as zero and a missing string as empty,
1163 /// so a record a later version wrote or an older one half-filled still lists rather than fails the
1164 /// whole history.
1165 pub fn from_dat(d: &Dat) -> Self {
1166 let m = match d {
1167 Dat::Map(m) => m,
1168 _ => return Self::default(),
1169 };
1170 let get_str = |key: &str| -> String {
1171 match m.get(&dat!(key)) {
1172 Some(Dat::Str(s)) => s.clone(),
1173 _ => String::new(),
1174 }
1175 };
1176 Self {
1177 slug: get_str("slug"),
1178 at: get_str("at"),
1179 attempted: as_usize(m.get(&dat!("attempted"))),
1180 sent: as_usize(m.get(&dat!("sent"))),
1181 failed: as_usize(m.get(&dat!("failed"))),
1182 suppressed: as_usize(m.get(&dat!("suppressed"))),
1183 }
1184 }
1185}
1186
1187/// A `usize` out of a daticle that may hold an integer under any of the widths jdat writes one as, or
1188/// zero where there is no readable number.
1189fn as_usize(d: Option<&Dat>) -> usize {
1190 match d {
1191 Some(Dat::U64(n)) => *n as usize,
1192 Some(Dat::U32(n)) => *n as usize,
1193 Some(Dat::U16(n)) => *n as usize,
1194 Some(Dat::U8(n)) => *n as usize,
1195 _ => 0,
1196 }
1197}
1198
1199/// Appends one send to the history, reading the list and writing it back with the entry on the end.
1200///
1201/// Index-driven, no scan: the whole history is one list under [`SENDS_KEY`], read, pushed to, and
1202/// written -- the same shape the subscriber index takes. Newest is last on disk; [`send_history`] hands
1203/// it back newest first.
1204pub fn record_send<
1205 const UIDL: usize,
1206 UID: NumIdDat<UIDL>,
1207 ENC: Encrypter,
1208 KH: Hasher,
1209 DB: Database<UIDL, UID, ENC, KH>,
1210>(
1211 db: &(Arc<RwLock<DB>>, UID),
1212 entry: &SendEntry,
1213)
1214 -> Outcome<()>
1215{
1216 let (db_arc, user) = db;
1217 let mut items = res!(sends_list(db));
1218 items.push(entry.to_dat());
1219 let list = Dat::List(items);
1220 let guard = lock_read!(db_arc);
1221 res!(guard.insert(dat!(SENDS_KEY), list, *user, None));
1222 Ok(())
1223}
1224
1225/// The raw history list, or an empty one where nothing has been sent.
1226fn sends_list<
1227 const UIDL: usize,
1228 UID: NumIdDat<UIDL>,
1229 ENC: Encrypter,
1230 KH: Hasher,
1231 DB: Database<UIDL, UID, ENC, KH>,
1232>(
1233 db: &(Arc<RwLock<DB>>, UID),
1234)
1235 -> Outcome<Vec<Dat>>
1236{
1237 let (db_arc, _) = db;
1238 let guard = lock_read!(db_arc);
1239 let val = match res!(guard.get(&dat!(SENDS_KEY), None)) {
1240 Some((v, _)) => v,
1241 // No history is a site that has sent nothing, not an error -- the empty log it never wrote.
1242 None => return Ok(Vec::new()),
1243 };
1244 match &val {
1245 Dat::List(items) => Ok(items.clone()),
1246 Dat::Vek(vek) => Ok(vek.as_slice().to_vec()),
1247 _ => Err(err!(
1248 "publish: the send history must be a list, not {:?}.", val.kind();
1249 Invalid, Input, Mismatch)),
1250 }
1251}
1252
1253/// Every recorded send, most recent first.
1254///
1255/// What the subscribers page draws its history table from. The list is stored oldest-first, as it was
1256/// appended, and reversed here so the newest send heads the table.
1257pub fn send_history<
1258 const UIDL: usize,
1259 UID: NumIdDat<UIDL>,
1260 ENC: Encrypter,
1261 KH: Hasher,
1262 DB: Database<UIDL, UID, ENC, KH>,
1263>(
1264 db: &(Arc<RwLock<DB>>, UID),
1265)
1266 -> Outcome<Vec<SendEntry>>
1267{
1268 let mut out: Vec<SendEntry> = res!(sends_list(db)).iter().map(SendEntry::from_dat).collect();
1269 out.reverse();
1270 Ok(out)
1271}
1272
1273
1274// ┌───────────────────────────────────────────────────────────────────────────┐
1275// │ EMAIL: building the messages │
1276// └───────────────────────────────────────────────────────────────────────────┘
1277
1278/// The moderation-alert message: one line, no comment text, from the site to an operator.
1279fn build_moderation_alert_email(from: &str, to: &str, site_name: &str, post_slug: &str) -> String {
1280 let who = if site_name.trim().is_empty() { fmt!("your site") } else { site_name.to_string() };
1281 let subject = fmt!("A comment is waiting on {}", who);
1282 let body = fmt!(
1283 "A comment on the post \"{slug}\" is held for your review on {who}.\r\n\
1284 \r\n\
1285 Open the site's manage area to read it in context and approve or bin it. This message \
1286 carries none of the comment's text on purpose -- what is waiting is unmoderated.\r\n\
1287 \r\n\
1288 You are told this because your address is on the alert list on the AI page. Remove it there \
1289 to stop these.\r\n",
1290 slug = post_slug, who = who,
1291 );
1292 let date = rfc5322_date_now();
1293 fmt!(
1294 "From: {from}\r\n\
1295 To: {to}\r\n\
1296 Subject: {subject}\r\n\
1297 Date: {date}\r\n\
1298 Message-ID: {msgid}\r\n\
1299 MIME-Version: 1.0\r\n\
1300 Auto-Submitted: auto-generated\r\n\
1301 Content-Type: text/plain; charset=utf-8\r\n\
1302 \r\n\
1303 {body}",
1304 from = from, to = to, subject = subject, date = date, msgid = message_id(from),
1305 body = body,
1306 )
1307}
1308
1309/// The bare address inside a From, for the SMTP reverse-path.
1310///
1311/// `News <news@example.com>` addresses a person and `news@example.com` addresses a mail server, and
1312/// only the second belongs in an envelope. A From carrying no angle brackets is already bare and is
1313/// returned as it stands, trimmed.
1314pub fn envelope_of(from: &str) -> &str {
1315 match (from.rfind('<'), from.rfind('>')) {
1316 (Some(a), Some(b)) if b > a => from[a + 1..b].trim(),
1317 _ => from.trim(),
1318 }
1319}
1320
1321/// The domain part of a bare address, empty where there is none.
1322pub fn domain_of(addr: &str) -> &str {
1323 match addr.rsplit_once('@') {
1324 Some((_, d)) => d.trim(),
1325 None => "",
1326 }
1327}
1328
1329/// A unique `Message-ID` for one message, in the sending domain.
1330///
1331/// **Every message needs one.** A message without a `Message-ID` cannot be threaded, cannot be
1332/// de-duplicated by a receiving server, and is treated by the large mail providers as the mark of
1333/// something not sent by real mail software -- which is exactly the judgement a confirmation link
1334/// cannot afford. The value must be unique: the clock gives it an order and the random tail keeps
1335/// two messages sent in one second apart.
1336fn message_id(from: &str) -> String {
1337 let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
1338 Ok(d) => d.as_secs(),
1339 Err(_) => 0,
1340 };
1341 let tail = Rand::generate_random_string(16, "abcdefghijklmnopqrstuvwxyz0123456789");
1342 let domain = domain_of(envelope_of(from));
1343 let domain = if domain.is_empty() { "localhost" } else { domain };
1344 fmt!("<{}.{}@{}>", secs, tail, domain)
1345}
1346
1347/// An RFC 5322 confirmation message: plain text, the confirm link, and a line saying why it
1348/// arrived.
1349///
1350/// Pure over its strings, so what a subscriber is sent can be tested without a socket. The body is
1351/// deliberately spare -- an address that never opted in gets a link and an explanation, no more.
1352fn build_confirmation_email(from: &str, to: &str, confirm_url: &str, site_name: &str) -> String {
1353 let who = if site_name.trim().is_empty() {
1354 fmt!("this site")
1355 } else {
1356 site_name.to_string()
1357 };
1358 let subject = fmt!("Confirm your subscription to {}", who);
1359 let body = fmt!(
1360 "Someone -- probably you -- asked to subscribe this address to {who}.\r\n\
1361 \r\n\
1362 To confirm and start receiving posts, follow this link:\r\n\
1363 \r\n\
1364 {url}\r\n\
1365 \r\n\
1366 If it was not you, ignore this message: without the link followed, this address \
1367 receives nothing further.\r\n",
1368 who = who,
1369 url = confirm_url,
1370 );
1371 let date = rfc5322_date_now();
1372 fmt!(
1373 "From: {from}\r\n\
1374 To: {to}\r\n\
1375 Subject: {subject}\r\n\
1376 Date: {date}\r\n\
1377 Message-ID: {msgid}\r\n\
1378 MIME-Version: 1.0\r\n\
1379 Auto-Submitted: auto-generated\r\n\
1380 Content-Type: text/plain; charset=utf-8\r\n\
1381 \r\n\
1382 {body}",
1383 from = from, to = to, subject = subject, date = date, msgid = message_id(from),
1384 body = body,
1385 )
1386}
1387
1388/// An RFC 5322 newsletter message: `multipart/alternative`, the post as HTML and as plain text, each
1389/// with a footer that carries the unsubscribe link -- as [CAN-SPAM] and the mailbox providers both
1390/// expect of bulk mail.
1391///
1392/// The HTML part is the post's own rendering, the same HTML a reader gets on the site, wrapped in a
1393/// minimal document and followed by the footer. The plain-text part is the title, the opening, and the
1394/// two links, for a reader whose client shows text. The Subject is the post's own title.
1395fn build_newsletter_email(
1396 from: &str,
1397 to: &str,
1398 post: &Post,
1399 online_url: &str,
1400 unsub_url: &str,
1401 site_name: &str,
1402) -> String {
1403 let boundary = fmt!("=_steel_{}", Rand::generate_random_string(24,
1404 "abcdefghijklmnopqrstuvwxyz0123456789"));
1405 let date = rfc5322_date_now();
1406
1407 // The plain-text alternative: title, opening, and the two links, wrapped where a client wants text.
1408 let text = fmt!(
1409 "{title}\r\n\r\n{excerpt}\r\n\r\nRead it online:\r\n{online}\r\n\r\n\
1410 --\r\nYou are receiving this because you confirmed a subscription{site}.\r\n\
1411 Unsubscribe: {unsub}\r\n",
1412 title = post.title,
1413 excerpt = post.excerpt,
1414 online = online_url,
1415 site = if site_name.trim().is_empty() { String::new() } else { fmt!(" to {}", site_name) },
1416 unsub = unsub_url,
1417 );
1418
1419 // The HTML alternative: the post's own rendering, then a footer with the same links, everything the
1420 // site did not itself render escaped where it lands in markup.
1421 let mut html = String::new();
1422 html.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n");
1423 html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
1424 html.push_str("<title>");
1425 escape_text(&mut html, &post.title);
1426 html.push_str("</title>\n</head>\n<body>\n<article>\n");
1427 // The prose was escaped where it was rendered; it is HTML by the time it reaches here.
1428 html.push_str(&post.html);
1429 html.push_str("\n</article>\n<hr>\n<footer>\n<p><a href=\"");
1430 escape_attr(&mut html, online_url);
1431 html.push_str("\">Read it online</a></p>\n<p>You are receiving this because you confirmed a \
1432 subscription");
1433 if !site_name.trim().is_empty() {
1434 html.push_str(" to ");
1435 escape_text(&mut html, site_name);
1436 }
1437 html.push_str(". <a href=\"");
1438 escape_attr(&mut html, unsub_url);
1439 html.push_str("\">Unsubscribe</a>.</p>\n</footer>\n</body>\n</html>\n");
1440
1441 fmt!(
1442 "From: {from}\r\n\
1443 To: {to}\r\n\
1444 Subject: {subject}\r\n\
1445 Date: {date}\r\n\
1446 Message-ID: {msgid}\r\n\
1447 MIME-Version: 1.0\r\n\
1448 List-Unsubscribe: <{unsub}>\r\n\
1449 List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n\
1450 Content-Type: multipart/alternative; boundary=\"{boundary}\"\r\n\
1451 \r\n\
1452 --{boundary}\r\n\
1453 Content-Type: text/plain; charset=utf-8\r\n\
1454 \r\n\
1455 {text}\r\n\
1456 --{boundary}\r\n\
1457 Content-Type: text/html; charset=utf-8\r\n\
1458 \r\n\
1459 {html}\r\n\
1460 --{boundary}--\r\n",
1461 from = from, to = to, subject = post.title, date = date, unsub = unsub_url,
1462 msgid = message_id(from),
1463 boundary = boundary, text = text, html = html,
1464 )
1465}
1466
1467/// The current moment as an RFC 5322 date, e.g. `Fri, 18 Jul 2026 10:00:00 +0000`.
1468///
1469/// Built from [`CalClock`] -- the fe2o3 calendar does the civil arithmetic -- with only the label
1470/// arrays here. A clock that will not read yields the epoch's date rather than failing a send.
1471fn rfc5322_date_now() -> String {
1472 let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
1473 Ok(d) => d.as_secs() as i64,
1474 Err(_) => 0,
1475 };
1476 match rfc5322_date(secs) {
1477 Ok(s) => s,
1478 Err(_) => fmt!("Thu, 01 Jan 1970 00:00:00 +0000"),
1479 }
1480}
1481
1482/// A unix second as an RFC 5322 date in UTC.
1483fn rfc5322_date(unix_secs: i64) -> Outcome<String> {
1484 let cc = res!(CalClock::from_unix_timestamp_seconds(unix_secs, CalClockZone::utc()));
1485 let dow = match cc.day_of_week() {
1486 DayOfWeek::Monday => "Mon",
1487 DayOfWeek::Tuesday => "Tue",
1488 DayOfWeek::Wednesday => "Wed",
1489 DayOfWeek::Thursday => "Thu",
1490 DayOfWeek::Friday => "Fri",
1491 DayOfWeek::Saturday => "Sat",
1492 DayOfWeek::Sunday => "Sun",
1493 };
1494 let months = [
1495 "Jan", "Feb", "Mar", "Apr", "May", "Jun",
1496 "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
1497 ];
1498 let mi = (cc.month().max(1).min(12) - 1) as usize;
1499 Ok(fmt!(
1500 "{dow}, {day:02} {mon} {year:04} {h:02}:{m:02}:{s:02} +0000",
1501 dow = dow, day = cc.day(), mon = months[mi], year = cc.year(),
1502 h = cc.hour(), m = cc.minute(), s = cc.second(),
1503 ))
1504}
1505
1506
1507#[cfg(test)]
1508mod tests {
1509 use super::*;
1510
1511 /// A base URL becomes a bare host, whatever scheme or trailing slash it wore.
1512 #[test]
1513 fn test_a_base_url_becomes_a_host_00() -> Outcome<()> {
1514 assert_eq!(host_of("https://mastodon.social"), "mastodon.social");
1515 assert_eq!(host_of("https://mastodon.social/"), "mastodon.social");
1516 assert_eq!(host_of(" http://example.test/ "), "example.test");
1517 assert_eq!(host_of("example.test"), "example.test");
1518 Ok(())
1519 }
1520
1521 /// The Mastodon body carries the text under `status`, and reads back as that text.
1522 #[test]
1523 fn test_a_mastodon_body_carries_the_text_01() -> Outcome<()> {
1524 let body = res!(mastodon_status_body("On rent https://x/asides/on-rent"));
1525 let m = res!(parse_json(&body));
1526 assert_eq!(json_str(&m, "status").as_deref(), Some("On rent https://x/asides/on-rent"));
1527 Ok(())
1528 }
1529
1530 /// The Bluesky session body names the handle and the password where Bluesky looks for them.
1531 #[test]
1532 fn test_a_bluesky_session_body_names_the_account_02() -> Outcome<()> {
1533 let body = res!(bluesky_session_body("me.bsky.social", "app-pw-1234"));
1534 let m = res!(parse_json(&body));
1535 assert_eq!(json_str(&m, "identifier").as_deref(), Some("me.bsky.social"));
1536 assert_eq!(json_str(&m, "password").as_deref(), Some("app-pw-1234"));
1537 Ok(())
1538 }
1539
1540 /// The Bluesky record body is a feed post under the repo, with the text and a timestamp.
1541 #[test]
1542 fn test_a_bluesky_record_body_is_a_feed_post_03() -> Outcome<()> {
1543 let body = res!(bluesky_record_body("did:plc:abc", "On rent", "2026-07-18T10:00:00Z"));
1544 let m = res!(parse_json(&body));
1545 assert_eq!(json_str(&m, "repo").as_deref(), Some("did:plc:abc"));
1546 assert_eq!(json_str(&m, "collection").as_deref(), Some("app.bsky.feed.post"));
1547 let record = match m.get(&dat!("record")) {
1548 Some(Dat::Map(r)) => r,
1549 other => return Err(err!(
1550 "the record must be a map, got {:?}", other; Test, Mismatch)),
1551 };
1552 assert_eq!(json_str(record, "text").as_deref(), Some("On rent"));
1553 assert_eq!(json_str(record, "$type").as_deref(), Some("app.bsky.feed.post"));
1554 assert_eq!(json_str(record, "createdAt").as_deref(), Some("2026-07-18T10:00:00Z"));
1555 Ok(())
1556 }
1557
1558 /// An at-uri becomes a bsky.app link; a URI of the wrong shape becomes nothing.
1559 #[test]
1560 fn test_an_at_uri_becomes_a_link_04() -> Outcome<()> {
1561 assert_eq!(
1562 at_uri_to_url("at://did:plc:abc/app.bsky.feed.post/3krxy"),
1563 Some(fmt!("https://bsky.app/profile/did:plc:abc/post/3krxy")),
1564 );
1565 assert_eq!(at_uri_to_url("https://not-an-at-uri"), None);
1566 assert_eq!(at_uri_to_url("at://did:plc:abc/app.bsky.feed.post/"), None);
1567 Ok(())
1568 }
1569
1570 /// A remote's reply is read back for the field a permalink lives in.
1571 #[test]
1572 fn test_a_permalink_is_read_from_a_reply_05() -> Outcome<()> {
1573 let mastodon_reply = r#"{"id":"1","url":"https://mastodon.social/@me/1","content":"x"}"#;
1574 let m = res!(parse_json(mastodon_reply));
1575 assert_eq!(json_str(&m, "url").as_deref(), Some("https://mastodon.social/@me/1"));
1576
1577 let bluesky_reply = r#"{"uri":"at://did:plc:abc/app.bsky.feed.post/3k","cid":"bafy"}"#;
1578 let m = res!(parse_json(bluesky_reply));
1579 assert_eq!(json_str(&m, "uri").as_deref(), Some("at://did:plc:abc/app.bsky.feed.post/3k"));
1580 Ok(())
1581 }
1582
1583 /// A unix second becomes an RFC 3339 UTC timestamp the epoch pins.
1584 #[test]
1585 fn test_a_unix_second_becomes_a_timestamp_06() -> Outcome<()> {
1586 // The epoch itself.
1587 let s = res!(iso_of(0));
1588 assert!(s.starts_with("1970-01-01T00:00:00"), "got: {}", s);
1589 // A known instant: 2026-07-18T10:00:00Z is 1_784_368_800.
1590 let s = res!(iso_of(1_784_368_800));
1591 assert!(s.starts_with("2026-07-18T10:00:00"), "got: {}", s);
1592 Ok(())
1593 }
1594
1595 /// Queueing derives a default for a new destination and keeps a hand-edited or already-sent one.
1596 #[test]
1597 fn test_queueing_keeps_edits_and_sends_08() -> Outcome<()> {
1598 let sent = Delivery {
1599 dest: Destination::Mastodon,
1600 rendition: Rendition { text: fmt!("old"), auto: true },
1601 state: DeliveryState::Sent { at: fmt!("t"), permalink: fmt!("https://m/1") },
1602 };
1603 let edited = Delivery {
1604 dest: Destination::Bluesky,
1605 rendition: Rendition { text: fmt!("my words"), auto: false },
1606 state: DeliveryState::Queued,
1607 };
1608 let existing = [sent.clone(), edited.clone()];
1609 let out = queue_deliveries(
1610 &existing,
1611 &[Destination::Mastodon, Destination::Bluesky],
1612 "On rent",
1613 "https://x/asides/on-rent",
1614 );
1615 // The sent one is untouched -- not re-derived, not re-opened.
1616 let m = match out.iter().find(|d| d.dest == Destination::Mastodon) {
1617 Some(d) => d,
1618 None => return Err(err!("Mastodon delivery was dropped"; Test, Missing)),
1619 };
1620 assert_eq!(m.state, sent.state);
1621 assert_eq!(m.rendition.text, "old");
1622 // The hand-edited one keeps its words.
1623 let b = match out.iter().find(|d| d.dest == Destination::Bluesky) {
1624 Some(d) => d,
1625 None => return Err(err!("Bluesky delivery was dropped"; Test, Missing)),
1626 };
1627 assert_eq!(b.rendition.text, "my words");
1628 assert!(!b.rendition.auto);
1629 Ok(())
1630 }
1631
1632 /// A destination dropped from the set loses its delivery; a new one gets a derived default, queued.
1633 #[test]
1634 fn test_queueing_drops_the_unchosen_and_adds_the_new_09() -> Outcome<()> {
1635 let existing = [Delivery::new(Destination::Mastodon, Rendition::default())];
1636 // Choose only Bluesky: Mastodon is dropped, Bluesky is new.
1637 let out = queue_deliveries(&existing, &[Destination::Bluesky], "On rent", "https://x/on-rent");
1638 assert_eq!(out.len(), 1);
1639 assert_eq!(out[0].dest, Destination::Bluesky);
1640 assert_eq!(out[0].state, DeliveryState::Queued);
1641 assert!(out[0].rendition.auto);
1642 assert!(out[0].rendition.text.ends_with("https://x/on-rent"));
1643 Ok(())
1644 }
1645
1646 /// A destinations block parses into per-remote creds, defaulting the Bluesky host and reading a
1647 /// remote it does not name as absent.
1648 #[test]
1649 fn test_a_destinations_block_parses_10() -> Outcome<()> {
1650 let mut masto = DaticleMap::new();
1651 masto.insert(dat!("base_url"), dat!("https://mastodon.social"));
1652 masto.insert(dat!("token"), dat!("secret-token"));
1653 let mut bsky = DaticleMap::new();
1654 bsky.insert(dat!("handle"), dat!("me.bsky.social"));
1655 bsky.insert(dat!("app_password"), dat!("app-pw"));
1656 let mut dests = DaticleMap::new();
1657 dests.insert(dat!("mastodon"), Dat::Map(masto));
1658 dests.insert(dat!("bluesky"), Dat::Map(bsky));
1659
1660 let creds = res!(DestCreds::from_datmap(&dests));
1661 let m = match &creds.mastodon {
1662 Some(m) => m,
1663 None => return Err(err!("mastodon creds did not parse"; Test, Missing)),
1664 };
1665 assert_eq!(m.base_url, "https://mastodon.social");
1666 assert_eq!(m.token, "secret-token");
1667 let b = match &creds.bluesky {
1668 Some(b) => b,
1669 None => return Err(err!("bluesky creds did not parse"; Test, Missing)),
1670 };
1671 assert_eq!(b.handle, "me.bsky.social");
1672 // The host defaulted, since the block named none.
1673 assert_eq!(b.host, BLUESKY_HOST_DEFAULT);
1674 assert_eq!(creds.offered(), vec![Destination::Mastodon, Destination::Bluesky]);
1675 Ok(())
1676 }
1677
1678 /// Credentials survive the round-trip through the store's daticle, and a half-written remote is
1679 /// dropped rather than read as broken.
1680 #[test]
1681 fn test_creds_round_trip_through_the_store_12() -> Outcome<()> {
1682 let creds = DestCreds {
1683 mastodon: Some(MastodonCreds {
1684 base_url: fmt!("https://mastodon.social"),
1685 token: fmt!("tok"),
1686 }),
1687 bluesky: Some(BlueskyCreds {
1688 host: fmt!("bsky.social"),
1689 handle: fmt!("me.bsky.social"),
1690 app_password: fmt!("pw"),
1691 }),
1692 };
1693 let back = DestCreds::from_dat(&creds.to_dat());
1694 assert_eq!(back, creds);
1695
1696 // A Mastodon block with no token is not half-read; it is dropped.
1697 let mut mm = DaticleMap::new();
1698 mm.insert(dat!("base_url"), dat!("https://m"));
1699 let mut m = DaticleMap::new();
1700 m.insert(dat!("mastodon"), Dat::Map(mm));
1701 let back = DestCreds::from_dat(&Dat::Map(m));
1702 assert_eq!(back.mastodon, None);
1703 Ok(())
1704 }
1705
1706 /// Console-set credentials win over config, per remote, and config fills the rest.
1707 #[test]
1708 fn test_console_creds_overlay_config_13() -> Outcome<()> {
1709 let config = DestCreds {
1710 mastodon: Some(MastodonCreds { base_url: fmt!("https://cfg"), token: fmt!("cfg-tok") }),
1711 bluesky: Some(BlueskyCreds {
1712 host: fmt!("bsky.social"), handle: fmt!("cfg"), app_password: fmt!("cfg-pw"),
1713 }),
1714 };
1715 // The console set only Mastodon.
1716 let console = DestCreds {
1717 mastodon: Some(MastodonCreds { base_url: fmt!("https://con"), token: fmt!("con-tok") }),
1718 bluesky: None,
1719 };
1720 let eff = console.overlay(config);
1721 // Mastodon is the console's; Bluesky falls back to config.
1722 let m = match &eff.mastodon { Some(m) => m, None => return Err(err!("no mastodon"; Test, Missing)) };
1723 assert_eq!(m.token, "con-tok");
1724 let b = match &eff.bluesky { Some(b) => b, None => return Err(err!("no bluesky"; Test, Missing)) };
1725 assert_eq!(b.handle, "cfg");
1726 Ok(())
1727 }
1728
1729 /// A credential block missing a required field is refused at load, not left to fail at send.
1730 #[test]
1731 fn test_a_missing_credential_is_refused_at_load_11() -> Outcome<()> {
1732 let mut masto = DaticleMap::new();
1733 masto.insert(dat!("base_url"), dat!("https://mastodon.social"));
1734 // No token.
1735 let mut dests = DaticleMap::new();
1736 dests.insert(dat!("mastodon"), Dat::Map(masto));
1737 assert!(DestCreds::from_datmap(&dests).is_err());
1738 Ok(())
1739 }
1740
1741 /// Only the wired destinations report themselves configured, and only when creds are present.
1742 #[test]
1743 fn test_creds_gate_which_destinations_are_offered_07() -> Outcome<()> {
1744 let creds = DestCreds {
1745 mastodon: Some(MastodonCreds::default()),
1746 bluesky: None,
1747 };
1748 assert!(creds.has(Destination::Mastodon));
1749 assert!(!creds.has(Destination::Bluesky));
1750 assert!(!creds.has(Destination::Email));
1751 assert!(!creds.has(Destination::X));
1752 Ok(())
1753 }
1754
1755 /// A stand-in post for the mail-building tests.
1756 fn a_post() -> Post {
1757 Post {
1758 slug: fmt!("on-rent"),
1759 title: fmt!("On rent"),
1760 author: String::new(),
1761 categories: Vec::new(),
1762 date: None,
1763 excerpt: fmt!("An opening sentence."),
1764 words: 3,
1765 html: fmt!("<h1>On rent</h1>\n<p>An opening sentence.</p>\n"),
1766 also_on: Vec::new(),
1767 tags: Vec::new(),
1768 ai_level: None,
1769 }
1770 }
1771
1772 /// The confirmation is plain text, names why it arrived, and carries the confirm link and nothing
1773 /// that would act on the reader's behalf beyond it.
1774 #[test]
1775 fn test_a_confirmation_carries_its_link_14() -> Outcome<()> {
1776 let msg = build_confirmation_email(
1777 "news@x.test", "me@example.com", "https://x.test/posts/confirm?token=abc", "README");
1778 assert!(msg.contains("From: news@x.test"), "got: {}", msg);
1779 assert!(msg.contains("To: me@example.com"), "got: {}", msg);
1780 assert!(msg.contains("Subject: Confirm your subscription to README"), "got: {}", msg);
1781 assert!(msg.contains("https://x.test/posts/confirm?token=abc"), "no confirm link: {}", msg);
1782 assert!(msg.contains("text/plain; charset=utf-8"), "not plain text: {}", msg);
1783 assert!(msg.contains("Date: "), "no date header: {}", msg);
1784 Ok(())
1785 }
1786
1787 /// The newsletter is multipart, carries the post's own HTML, its title as the subject, and the
1788 /// unsubscribe link in the body and the `List-Unsubscribe` header both.
1789 #[test]
1790 fn test_a_newsletter_carries_the_post_and_unsub_15() -> Outcome<()> {
1791 let post = a_post();
1792 let msg = build_newsletter_email(
1793 "README <news@x.test>", "me@example.com", &post,
1794 "https://x.test/posts/on-rent", "https://x.test/posts/unsubscribe?token=zzz", "README");
1795 assert!(msg.contains("Subject: On rent"), "the subject is not the title: {}", msg);
1796 assert!(msg.contains("multipart/alternative"), "not multipart: {}", msg);
1797 assert!(msg.contains("text/plain; charset=utf-8"), "no text part: {}", msg);
1798 assert!(msg.contains("text/html; charset=utf-8"), "no html part: {}", msg);
1799 assert!(msg.contains("<h1>On rent</h1>"), "the post's HTML is not in the message: {}", msg);
1800 assert!(msg.contains("https://x.test/posts/unsubscribe?token=zzz"), "no unsubscribe link: {}", msg);
1801 assert!(msg.contains("List-Unsubscribe: <https://x.test/posts/unsubscribe?token=zzz>"),
1802 "no List-Unsubscribe header: {}", msg);
1803 Ok(())
1804 }
1805
1806 /// A unix second becomes an RFC 5322 date the epoch pins, ending in a UTC offset.
1807 #[test]
1808 fn test_a_unix_second_becomes_an_rfc5322_date_16() -> Outcome<()> {
1809 // 2026-07-18T10:00:00Z is 1_784_368_800.
1810 let s = res!(rfc5322_date(1_784_368_800));
1811 assert!(s.contains("18 Jul 2026"), "got: {}", s);
1812 assert!(s.contains("10:00:00"), "got: {}", s);
1813 assert!(s.ends_with("+0000"), "got: {}", s);
1814 // The epoch itself was a Thursday.
1815 let s = res!(rfc5322_date(0));
1816 assert!(s.starts_with("Thu, 01 Jan 1970"), "got: {}", s);
1817 Ok(())
1818 }
1819
1820 /// The newsletter From is the site's own where it names one, and the sender's default otherwise.
1821 #[test]
1822 fn test_the_newsletter_from_resolves_17() -> Outcome<()> {
1823 let sender = res!(MailSender::new(
1824 "mail.x.test".to_string(), Vec::new(), "news@x.test".to_string()));
1825 let named = PublishConfig { newsletter_from: fmt!("README <hi@x.test>"), ..Default::default() };
1826 assert_eq!(named.newsletter_from(&sender), "README <hi@x.test>");
1827 let unnamed = PublishConfig { newsletter_from: String::new(), ..Default::default() };
1828 assert_eq!(unnamed.newsletter_from(&sender), "news@x.test");
1829 Ok(())
1830 }
1831
1832 /// A message carries a unique Message-ID in the domain it is sent from.
1833 ///
1834 /// Its absence is read by the large providers as the mark of something not sent by real mail
1835 /// software. The confirmation to a new subscriber is the one message that must arrive, so it is
1836 /// the one that can least afford the judgement.
1837 #[test]
1838 fn test_a_message_carries_an_id_26() -> Outcome<()> {
1839 let a = message_id("need2know <news@need2know.ai>");
1840 assert!(a.starts_with('<') && a.ends_with('>'), "not in angle brackets: {}", a);
1841 assert!(a.ends_with("@need2know.ai>"), "not in the sending domain: {}", a);
1842 // Unique, or a receiving server is entitled to treat the second as a duplicate of the first.
1843 assert_ne!(a, message_id("need2know <news@need2know.ai>"));
1844 // A From with no domain still yields a well-formed id rather than a malformed header.
1845 assert!(message_id("postmaster").ends_with("@localhost>"));
1846 Ok(())
1847 }
1848
1849 /// The domain is taken from the address, not from the display name around it.
1850 #[test]
1851 fn test_the_domain_comes_from_the_address_27() -> Outcome<()> {
1852 assert_eq!(domain_of(envelope_of("need2know <news@need2know.ai>")), "need2know.ai");
1853 assert_eq!(domain_of(envelope_of("news@x.test")), "x.test");
1854 assert_eq!(domain_of("not-an-address"), "");
1855 Ok(())
1856 }
1857
1858 /// The envelope takes the address alone, whatever display name the From wears.
1859 ///
1860 /// The documented shape of `newsletter_from` carries a display name, and that whole string went
1861 /// into `MAIL FROM:<...>`. A receiving server refuses that reverse-path with a 5xx, which this
1862 /// module reads as a permanent failure and answers by suppressing the subscriber -- so every
1863 /// address a named From was ever used with would have been marked bounced and never mailed
1864 /// again.
1865 #[test]
1866 fn test_the_envelope_takes_the_address_alone_25() -> Outcome<()> {
1867 assert_eq!(envelope_of("README <hi@x.test>"), "hi@x.test");
1868 assert_eq!(envelope_of("need2know <news@need2know.ai>"), "news@need2know.ai");
1869 // Already bare, and stays so.
1870 assert_eq!(envelope_of("news@x.test"), "news@x.test");
1871 assert_eq!(envelope_of(" news@x.test "), "news@x.test");
1872 // Whitespace inside the brackets is not part of the address.
1873 assert_eq!(envelope_of("A Name < news@x.test >"), "news@x.test");
1874 // A name carrying an angle bracket does not truncate the address: the last pair wins.
1875 assert_eq!(envelope_of("A > B <news@x.test>"), "news@x.test");
1876 // Nothing usable is returned as it stands rather than as an empty reverse-path, which would
1877 // silently become a bounce address.
1878 assert_eq!(envelope_of("not an address"), "not an address");
1879 Ok(())
1880 }
1881
1882 /// A send report becomes a history entry that carries every count, and the entry survives the trip
1883 /// through its daticle.
1884 #[test]
1885 fn test_a_send_entry_round_trips_18() -> Outcome<()> {
1886 let report = SendReport { attempted: 10, sent: 7, failed: 2, suppressed: 1 };
1887 let entry = SendEntry::of("on-rent", "2026-07-18T10:00:00Z", &report);
1888 assert_eq!(entry.slug, "on-rent");
1889 assert_eq!(entry.at, "2026-07-18T10:00:00Z");
1890 assert_eq!(entry.attempted, 10);
1891 assert_eq!(entry.sent, 7);
1892 assert_eq!(entry.failed, 2);
1893 assert_eq!(entry.suppressed, 1);
1894
1895 let back = SendEntry::from_dat(&entry.to_dat());
1896 assert_eq!(back, entry);
1897
1898 // A record missing a count reads that count as zero rather than failing the whole history.
1899 let mut sparse = DaticleMap::new();
1900 sparse.insert(dat!("slug"), dat!("x"));
1901 let back = SendEntry::from_dat(&Dat::Map(sparse));
1902 assert_eq!(back.slug, "x");
1903 assert_eq!(back.attempted, 0);
1904 assert_eq!(back.sent, 0);
1905 Ok(())
1906 }
1907}