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
140fn normalized(address: &str) -> String {
141    address.trim().to_lowercase()
142}
143
144pub async fn scopes(read: &Read, address: &str) -> Result<Vec<Scope>> {
145    let held = Suppression::query()
146        .where_(ADDRESS.eq(normalized(address)))
147        .all(read)
148        .await?;
149    Ok(held
150        .into_iter()
151        .map(|suppression| suppression.scope)
152        .collect())
153}
154
155pub(crate) async fn reachable(read: &Read, mail: &Mail) -> Result<Vec<Address>> {
156    let emails = mail.to().iter().map(|to| normalized(to.email().as_str()));
157    let held = Suppression::query()
158        .where_(ADDRESS.in_(emails))
159        .all(read)
160        .await?;
161    let reachable = mail.to().iter().filter(|to| {
162        let email = normalized(to.email().as_str());
163        !held.iter().any(|suppression| {
164            suppression.address == email && suppression.scope.blocks(mail.list())
165        })
166    });
167    Ok(reachable.cloned().collect())
168}
169
170/// A new suppression, or none when one already covers the scope.
171pub async fn suppress(
172    read: &Read,
173    address: &str,
174    scope: Scope,
175    reason: Reason,
176) -> Result<Option<Suppression>> {
177    Ok(suppress_each(read, &[address], &scope, reason).await?.pop())
178}
179
180/// A new suppression for each address no held one covers, read in one query.
181pub(crate) async fn suppress_each(
182    read: &Read,
183    addresses: &[&str],
184    scope: &Scope,
185    reason: Reason,
186) -> Result<Vec<Suppression>> {
187    let mut addresses: Vec<String> = addresses
188        .iter()
189        .map(|address| normalized(address))
190        .collect();
191    addresses.sort_unstable();
192    addresses.dedup();
193    if addresses.is_empty() {
194        return Ok(Vec::new());
195    }
196    let held = Suppression::query()
197        .where_(ADDRESS.in_(addresses.iter().cloned()))
198        .all(read)
199        .await?;
200    let covered = |address: &str| {
201        held.iter().any(|suppression| {
202            suppression.address == address
203                && (suppression.scope == *scope || suppression.scope == Scope::All)
204        })
205    };
206    addresses
207        .into_iter()
208        .filter(|address| !covered(address))
209        .map(|address| {
210            Ok(Suppression {
211                meta: Meta::new()?,
212                address,
213                scope: scope.clone(),
214                reason,
215            })
216        })
217        .collect()
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn a_scope_blocks_only_the_mail_it_names() {
226        let list = Scope::List("news".to_owned());
227
228        assert!(Scope::All.blocks(None));
229        assert!(!Scope::Bulk.blocks(None));
230        assert!(Scope::Bulk.blocks(Some("news")));
231        assert!(list.blocks(Some("news")));
232        assert!(!list.blocks(Some("offers")));
233        assert!(!list.blocks(None));
234        assert_eq!(Scope::from_stored(&list.to_stored()).unwrap(), list);
235    }
236
237    #[test]
238    fn a_suppressed_address_is_written_as_a_parsed_address_is() {
239        let parsed = sofie_values::Email::parse("ÇAĞRI@Example.com").unwrap();
240
241        assert_eq!(normalized(" ÇAĞRI@Example.com "), parsed.as_str());
242    }
243}