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 {
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
174pub 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
184pub(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}