1use 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 All,
15 Bulk,
17 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 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
170pub 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
180pub(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}