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 | |
| 24 | use 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 | |
| 38 | use oxedyne_fe2o3_core::{ |
| 39 | prelude::*, |
| 40 | rand::Rand, |
| 41 | }; |
| 42 | use oxedyne_fe2o3_iop_crypto::enc::Encrypter; |
| 43 | use oxedyne_fe2o3_iop_db::api::Database; |
| 44 | use oxedyne_fe2o3_iop_hash::api::Hasher; |
| 45 | use oxedyne_fe2o3_jdat::{ |
| 46 | prelude::*, |
| 47 | id::NumIdDat, |
| 48 | string::dec::DecoderConfig, |
| 49 | usr::{ |
| 50 | UsrKind, |
| 51 | UsrKindCode, |
| 52 | UsrKindId, |
| 53 | }, |
| 54 | }; |
| 55 | use oxedyne_fe2o3_datime::{ |
| 56 | constant::DayOfWeek, |
| 57 | format::rfc9557::Rfc9557Format, |
| 58 | time::{ |
| 59 | CalClock, |
| 60 | CalClockZone, |
| 61 | }, |
| 62 | }; |
| 63 | use 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 | }; |
| 78 | use oxedyne_fe2o3_text::doc::html::{ |
| 79 | escape_attr, |
| 80 | escape_text, |
| 81 | }; |
| 82 | |
| 83 | use std::{ |
| 84 | collections::BTreeMap, |
| 85 | path::Path, |
| 86 | sync::{ |
| 87 | Arc, |
| 88 | RwLock, |
| 89 | }, |
| 90 | time::{ |
| 91 | SystemTime, |
| 92 | UNIX_EPOCH, |
| 93 | }, |
| 94 | }; |
| 95 | |
| 96 | use 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. |
| 101 | pub const BLUESKY_HOST_DEFAULT: &str = "bsky.social"; |
| 102 | |
| 103 | |
| 104 | #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| 105 | pub 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)] |
| 111 | pub 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)] |
| 125 | pub struct DestCreds { |
| 126 | pub mastodon: Option<MastodonCreds>, |
| 127 | pub bluesky: Option<BlueskyCreds>, |
| 128 | } |
| 129 | |
| 130 | impl 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 | |
| 218 | impl 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 | |
| 253 | impl 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 | |
| 297 | fn as_map(d: &Dat) -> Option<&DaticleMap> { |
| 298 | match d { |
| 299 | Dat::Map(m) => Some(m), |
| 300 | _ => None, |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | fn 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 | |
| 312 | pub 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. |
| 316 | pub 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. |
| 338 | pub 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. |
| 359 | pub 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. |
| 375 | fn 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. |
| 393 | pub 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. |
| 437 | pub 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. |
| 497 | fn 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. |
| 507 | pub 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. |
| 547 | pub 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>"}`. |
| 582 | fn 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. |
| 598 | pub 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>"}`. |
| 678 | fn 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. |
| 687 | fn 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. |
| 705 | fn 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. |
| 723 | fn 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. |
| 732 | fn 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. |
| 740 | fn is_success(code: u16) -> bool { |
| 741 | (200..300).contains(&code) |
| 742 | } |
| 743 | |
| 744 | fn 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 | |
| 758 | fn 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. |
| 771 | pub 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. |
| 780 | pub 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)] |
| 801 | pub 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 | |
| 812 | impl 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 | |
| 823 | impl 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. |
| 961 | impl 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)] |
| 978 | pub 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. |
| 998 | pub 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. |
| 1071 | pub 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 | |
| 1121 | pub 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)] |
| 1129 | pub 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 | |
| 1138 | impl 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. |
| 1189 | fn 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. |
| 1204 | pub 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. |
| 1226 | fn 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. |
| 1257 | pub 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. |
| 1279 | fn 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. |
| 1314 | pub 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. |
| 1322 | pub 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. |
| 1336 | fn 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. |
| 1352 | fn 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. |
| 1395 | fn 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. |
| 1471 | fn 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. |
| 1483 | fn 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)] |
| 1508 | mod 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 | } |