Skip to main content

sofie_mail/
suppression.rs

1//! Addresses mail no longer goes to, kept in sofie-postgres's `migrations/0012_mail_suppressions.sql`.
2
3use sofie_core::{Cast, ColumnReader, ColumnSpec, Error, Result, SqlType, SqlValue, Uuid};
4use sofie_db::{Aggregate, Behavior, Column, Field, Meta, Read, Select, Timestamps};
5
6use crate::{Address, Mail};
7
8const TEXT: &[ColumnSpec] = &[ColumnSpec::new("", SqlType::Text)];
9const LIST_PREFIX: &str = "list:";
10
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub enum Scope {
13    /// Nothing: the address bounced for good.
14    All,
15    /// No bulk mail of any list: its owner complained of one.
16    Bulk,
17    /// No bulk mail of this list: its owner unsubscribed.
18    List(String),
19}
20
21impl Scope {
22    fn to_stored(&self) -> String {
23        match self {
24            Self::All => "all".to_owned(),
25            Self::Bulk => "bulk".to_owned(),
26            Self::List(list) => format!("{LIST_PREFIX}{list}"),
27        }
28    }
29
30    fn from_stored(text: &str) -> Result<Self> {
31        match text {
32            "all" => Ok(Self::All),
33            "bulk" => Ok(Self::Bulk),
34            _ => text
35                .strip_prefix(LIST_PREFIX)
36                .map(|list| Self::List(list.to_owned()))
37                .ok_or_else(|| {
38                    Error::internal_message("a stored suppression has an unknown scope")
39                }),
40        }
41    }
42
43    /// Transactional mail has no list and stops only for `All`.
44    pub(crate) fn blocks(&self, list: Option<&str>) -> bool {
45        match self {
46            Self::All => true,
47            Self::Bulk => list.is_some(),
48            Self::List(suppressed) => list == Some(suppressed.as_str()),
49        }
50    }
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum Reason {
55    Bounce,
56    Complaint,
57    Unsubscribe,
58}
59
60impl Reason {
61    const fn as_str(self) -> &'static str {
62        match self {
63            Self::Bounce => "bounce",
64            Self::Complaint => "complaint",
65            Self::Unsubscribe => "unsubscribe",
66        }
67    }
68
69    fn parse(text: &str) -> Result<Self> {
70        match text {
71            "bounce" => Ok(Self::Bounce),
72            "complaint" => Ok(Self::Complaint),
73            "unsubscribe" => Ok(Self::Unsubscribe),
74            _ => Err(Error::internal_message(
75                "a stored suppression has an unknown reason",
76            )),
77        }
78    }
79}
80
81pub struct Suppression {
82    meta: Meta,
83    address: String,
84    scope: Scope,
85    reason: Reason,
86}
87
88const ADDRESS: Column<Suppression, String> = Column::new("address");
89
90impl Aggregate for Suppression {
91    const NAME: &'static str = "mail_suppressions";
92    const MODULE: &'static str = "sofie";
93    const FIELDS: &'static [Field] = &[
94        Field::new("address", String::COLUMNS, false),
95        Field::new("scope", TEXT, false),
96        Field::new("reason", TEXT, false),
97    ];
98    const BEHAVIORS: &'static [&'static dyn Behavior] = &[&Timestamps];
99
100    fn meta(&self) -> &Meta {
101        &self.meta
102    }
103
104    fn meta_mut(&mut self) -> &mut Meta {
105        &mut self.meta
106    }
107
108    fn encode(&self, out: &mut Vec<SqlValue>) {
109        self.address.encode(out);
110        out.push(SqlValue::Text(self.scope.to_stored()));
111        out.push(SqlValue::Text(self.reason.as_str().to_owned()));
112    }
113
114    fn decode(meta: Meta, row: &mut dyn ColumnReader) -> Result<Self> {
115        Ok(Self {
116            meta,
117            address: String::decode(row)?,
118            scope: Scope::from_stored(row.text()?)?,
119            reason: Reason::parse(row.text()?)?,
120        })
121    }
122
123    fn owner(&self) -> Option<Uuid> {
124        None
125    }
126}
127
128impl Suppression {
129    #[must_use]
130    pub fn scope(&self) -> &Scope {
131        &self.scope
132    }
133
134    #[must_use]
135    pub fn reason(&self) -> Reason {
136        self.reason
137    }
138}
139
140/// A provider's report may name an address no parse accepts; it is still held, lowercased.
141fn normalized(address: &str) -> String {
142    sofie_values::Email::parse(address).map_or_else(
143        |_| address.trim().to_lowercase(),
144        |email| email.as_str().to_owned(),
145    )
146}
147
148pub async fn scopes(read: &Read, address: &str) -> Result<Vec<Scope>> {
149    let held = Suppression::query()
150        .where_(ADDRESS.eq(normalized(address)))
151        .all(read)
152        .await?;
153    Ok(held
154        .into_iter()
155        .map(|suppression| suppression.scope)
156        .collect())
157}
158
159pub(crate) async fn reachable(read: &Read, mail: &Mail) -> Result<Vec<Address>> {
160    let emails = mail.to().iter().map(|to| normalized(to.email().as_str()));
161    let held = Suppression::query()
162        .where_(ADDRESS.in_(emails))
163        .all(read)
164        .await?;
165    let reachable = mail.to().iter().filter(|to| {
166        let email = normalized(to.email().as_str());
167        !held.iter().any(|suppression| {
168            suppression.address == email && suppression.scope.blocks(mail.list())
169        })
170    });
171    Ok(reachable.cloned().collect())
172}
173
174/// A new suppression, or none when one already covers the scope.
175pub async fn suppress(
176    read: &Read,
177    address: &str,
178    scope: Scope,
179    reason: Reason,
180) -> Result<Option<Suppression>> {
181    Ok(suppress_each(read, &[address], &scope, reason).await?.pop())
182}
183
184/// A new suppression for each address no held one covers, read in one query.
185pub(crate) async fn suppress_each(
186    read: &Read,
187    addresses: &[&str],
188    scope: &Scope,
189    reason: Reason,
190) -> Result<Vec<Suppression>> {
191    let mut addresses: Vec<String> = addresses
192        .iter()
193        .map(|address| normalized(address))
194        .collect();
195    addresses.sort_unstable();
196    addresses.dedup();
197    if addresses.is_empty() {
198        return Ok(Vec::new());
199    }
200    let held = Suppression::query()
201        .where_(ADDRESS.in_(addresses.iter().cloned()))
202        .all(read)
203        .await?;
204    let covered = |address: &str| {
205        held.iter().any(|suppression| {
206            suppression.address == address
207                && (suppression.scope == *scope || suppression.scope == Scope::All)
208        })
209    };
210    addresses
211        .into_iter()
212        .filter(|address| !covered(address))
213        .map(|address| {
214            Ok(Suppression {
215                meta: Meta::new()?,
216                address,
217                scope: scope.clone(),
218                reason,
219            })
220        })
221        .collect()
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn a_scope_blocks_only_the_mail_it_names() {
230        let list = Scope::List("news".to_owned());
231
232        assert!(Scope::All.blocks(None));
233        assert!(!Scope::Bulk.blocks(None));
234        assert!(Scope::Bulk.blocks(Some("news")));
235        assert!(list.blocks(Some("news")));
236        assert!(!list.blocks(Some("offers")));
237        assert!(!list.blocks(None));
238        assert_eq!(Scope::from_stored(&list.to_stored()).unwrap(), list);
239    }
240
241    #[test]
242    fn a_suppressed_address_is_written_as_a_parsed_address_is() {
243        let parsed = sofie_values::Email::parse("ÇAĞRI@Example.com").unwrap();
244
245        assert_eq!(normalized(" ÇAĞRI@Example.com "), parsed.as_str());
246    }
247
248    #[test]
249    fn a_reported_address_is_folded_as_a_parsed_address_is() {
250        let parsed = sofie_values::Email::parse("ilker@example.com").unwrap();
251
252        assert_eq!(normalized("İlker@example.com"), parsed.as_str());
253        assert_eq!(normalized("I\u{307}lker@example.com"), parsed.as_str());
254    }
255}