1use crate::assistant::{AssistantSettings, PROVIDERS};
4use crate::error::Error;
5use crate::goals::CodedError;
6use crate::http::Url;
7use crate::js::{self, Object, Value};
8use crate::mail::secret::{seal, unseal};
9use crate::mail::transports::{MailError, Message, SERVICES, check_config, send};
10use crate::reports::{ReportPeriod, build_report, last_period};
11use crate::runlight::{Runlight, is_email};
12use crate::safefetch::metadata_host;
13use crate::store::{ReportRow, SiteRow};
14
15fn mail_error(message: &str, code: &str) -> Error {
16 Error::Mail(CodedError::new(message, code, &[]))
17}
18
19fn app_failed(via: &str, detail: String) -> Error {
21 let message = if detail.is_empty() {
22 format!("{via} could not send it")
23 } else {
24 format!("{via} could not send it: {detail}")
25 };
26 Error::Mail(CodedError::new(message, "mail_app_failed", &[("via", via), ("detail", &detail)]))
27}
28
29impl Runlight {
30 pub async fn mail_settings(&self) -> Result<Option<(Object, &'static str)>, Error> {
33 if let Some(code) = &self.0.mail_in_code {
34 return Ok(Some((code.clone(), "code")));
35 }
36 self.init().await?;
37 let sealed = self.store().setting("mail").await?;
38 if let Some(Value::Object(o)) =
39 sealed.and_then(|s| unseal(&s, self.secret())).and_then(|opened| js::parse(&opened).ok())
40 {
41 let offered =
43 self.0.mail_services.as_ref().is_none_or(|offered| {
44 offered.iter().any(|s| o.get("service").and_then(Value::as_str) == Some(s.id))
45 });
46 if offered {
47 return Ok(Some((o, "dashboard")));
48 }
49 }
50 let Some(app) = &self.0.app_mailer else { return Ok(None) };
51 let mut settings = Object::new().with("service", "app").with("from", app.from.as_str());
52 if let Some(name) = &app.from_name {
53 settings.set("fromName", name.as_str());
54 }
55 settings.set("via", app.via.as_str());
56 Ok(Some((settings, "app")))
57 }
58
59 pub async fn save_mail_settings(&self, input: Option<&Value>) -> Result<(), Error> {
62 if self.0.mail_in_code.is_some() {
63 return Err(mail_error("The mail service is set in code", "mail_in_code"));
64 }
65 let Some(input) = input else {
66 self.store().set_setting("mail", None).await?;
67 return Ok(());
68 };
69 let before = self.mail_settings().await?.filter(|b| b.1 == "dashboard").map(|b| b.0);
71 let Some(service) = SERVICES.iter().find(|s| input.get("service").and_then(Value::as_str) == Some(s.id)) else {
72 return Err(mail_error("Pick a mail service", "mail_service"));
73 };
74 if !self.mail_services().iter().any(|s| s.id == service.id) {
75 return Err(Error::Mail(CodedError::new(
76 format!("{} is not offered here. Pick another mail service", service.name),
77 "mail_service",
78 &[("service", service.id)],
79 )));
80 }
81 let text = |k: &str| js::trim(&js::str_or_empty(input.get(k))).to_string();
82 let mut settings = Object::new();
83 settings.set("service", service.id);
84 for f in service.fields.iter().filter(|f| !f.secret) {
85 settings.set(f.name, text(f.name));
86 }
87 let same = before.as_ref().is_some_and(|b| {
90 b.get("service").and_then(Value::as_str) == Some(service.id)
91 && service
92 .fields
93 .iter()
94 .all(|f| f.secret || js::str_or_empty(b.get(f.name)) == js::str_or_empty(settings.get(f.name)))
95 });
96 for f in service.fields.iter().filter(|f| f.secret) {
97 let given = text(f.name);
98 let value = if given.is_empty() && same {
99 js::str_or_empty(before.as_ref().and_then(|b| b.get(f.name)))
100 } else {
101 given
102 };
103 settings.set(f.name, value);
104 }
105 let from = text("from");
106 if !is_email(&from) {
107 return Err(mail_error("Enter the address reports come from, like reports@example.com", "mail_from"));
108 }
109 let from_name = js::head16(&text("fromName"), 80);
110 settings.set("from", from);
111 if !from_name.is_empty() {
112 settings.set("fromName", from_name);
113 }
114 check_config(&settings).map_err(Error::Mail)?;
115 let host = js::str_or_empty(settings.get("host"));
116 if service.id == "smtp" && !host.is_empty() && metadata_host(&host, None).await {
117 return Err(Error::Mail(CodedError::new(
118 format!("{host} is a link-local or cloud metadata address, which Runlight never connects to"),
119 "address_refused",
120 &[("host", &host)],
121 )));
122 }
123 let sealed = seal(&settings.to_json(), self.secret());
124 self.store().set_setting("mail", Some(&sealed)).await?;
125 Ok(())
126 }
127
128 pub async fn send_mail(&self, mut message: Message) -> Result<(), Error> {
130 let Some((settings, source)) = self.mail_settings().await? else {
131 return Err(mail_error("Set up a mail service first", "mail_unset"));
132 };
133 message.from = js::str_or_empty(settings.get("from"));
134 message.from_name = settings.get("fromName").map(js::js_string);
135 let Some(app) = self.0.app_mailer.as_ref().filter(|_| source == "app") else {
136 return send(&settings, &message, self.fetcher(), self.now()).await.map_err(Error::Mail);
137 };
138 (app.send)(message).await.map_err(|error| match error.downcast::<MailError>() {
139 Ok(mail) => Error::Mail(*mail),
140 Err(error) => match error.downcast::<Error>() {
141 Ok(own) if matches!(*own, Error::Mail(_)) => *own,
143 Ok(own) => app_failed(&app.via, own.to_string()),
144 Err(error) => app_failed(&app.via, error.to_string()),
145 },
146 })
147 }
148
149 pub async fn send_reports(&self) -> Result<(i64, i64), Error> {
152 self.init().await?;
153 let (mut sent, mut failed) = (0, 0);
154 let reports = self.store().reports(None).await?;
155 if reports.is_empty() || self.mail_settings().await?.is_none() {
156 return Ok((sent, failed));
157 }
158 let now = self.now();
159 for r in reports {
160 let Some(site) = self.site(Some(&r.site)) else { continue };
161 let period = last_period(&r.frequency, now, &site.timezone);
162 if now < period.due_at || r.last_period == period.key {
163 continue;
164 }
165 if !self.store().claim_report(&r.id, &period.key, now).await? {
166 continue;
167 }
168 match self.deliver_report(&r, &site, Some(&period)).await {
169 Ok(()) => sent += 1,
170 Err(error) => {
171 self.store().release_report(&r.id, &period.key, &r.last_period).await?;
172 eprintln!(
173 "Runlight: could not send the {} report for {} to {}: {}",
174 r.frequency,
175 site.name,
176 r.email,
177 error.message()
178 );
179 failed += 1;
180 }
181 }
182 }
183 Ok((sent, failed))
184 }
185
186 pub async fn deliver_report(
188 &self,
189 r: &ReportRow,
190 site: &SiteRow,
191 period: Option<&ReportPeriod>,
192 ) -> Result<(), Error> {
193 let owned;
194 let period = match period {
195 Some(p) => p,
196 None => {
197 owned = last_period(&r.frequency, self.now(), &site.timezone);
198 &owned
199 }
200 };
201 let unsubscribe = format!("{}/unsubscribe/{}", r.origin, r.token);
202 let dashboard = format!("{}/?site={}", r.origin, crate::sources::encode_uri_component(&site.id));
203 let report = build_report(self.store(), site, &r.frequency, period, &r.lang, &dashboard, &unsubscribe).await?;
204 self.send_mail(Message {
205 to: r.email.clone(),
206 subject: report.subject,
207 html: report.html,
208 text: report.text,
209 headers: vec![
210 ("List-Unsubscribe".into(), format!("<{unsubscribe}>")),
211 ("List-Unsubscribe-Post".into(), "List-Unsubscribe=One-Click".into()),
212 ],
213 ..Message::default()
214 })
215 .await
216 }
217
218 pub async fn assistant_settings(&self) -> Result<Option<AssistantSettings>, Error> {
220 let Some(stored) = self.store().setting("assistant").await? else { return Ok(None) };
221 let Some(opened) = unseal(&stored, self.secret()) else { return Ok(None) };
222 let Ok(v) = js::parse(&opened) else { return Ok(None) };
223 let s = |k: &str| js::str_or_empty(v.get(k));
224 Ok(Some(AssistantSettings {
225 provider: s("provider"),
226 model: s("model"),
227 base_url: s("baseUrl"),
228 key: s("key"),
229 }))
230 }
231
232 pub async fn save_assistant_settings(&self, input: Option<&Value>) -> Result<(), Error> {
234 let Some(input) = input else {
235 self.store().set_setting("assistant", None).await?;
236 return Ok(());
237 };
238 let Some(provider) = PROVIDERS.iter().find(|p| input.get("provider").and_then(Value::as_str) == Some(p.id))
239 else {
240 return Err(Error::settings("Choose a provider", "assistant_provider", &[]));
241 };
242 let base_url = js::trim(&js::str_or_empty(input.get("baseUrl"))).trim_end_matches('/').to_string();
243 if !base_url.is_empty() {
244 let parsed = Url::parse(&base_url).filter(|u| u.protocol() == "https:" || u.protocol() == "http:");
245 let Some(parsed) = parsed else {
246 return Err(Error::settings(
247 "Enter the service's address, starting with https://",
248 "assistant_address_bad",
249 &[],
250 ));
251 };
252 let host = parsed.hostname();
253 if metadata_host(&host, None).await {
254 return Err(Error::settings(
255 format!("{host} is a link-local or cloud metadata address, which Runlight never connects to"),
256 "address_refused",
257 &[("host", &host)],
258 ));
259 }
260 }
261 if base_url.is_empty() && provider.base_url.is_empty() {
262 return Err(Error::settings("Enter the service's address", "assistant_address", &[]));
263 }
264 let model = js::head16(js::trim(&js::str_or_empty(input.get("model"))), 200);
265 if model.is_empty() && provider.model.is_empty() {
266 return Err(Error::settings("Enter the model to use", "assistant_model", &[]));
267 }
268 let before = self.assistant_settings().await?;
269 let mut key = js::trim(&js::str_or_empty(input.get("key"))).to_string();
270 let address = |b: &str| if b.is_empty() { provider.base_url.to_string() } else { b.to_string() };
272 if key.is_empty()
273 && let Some(b) = &before
274 && b.provider == provider.id
275 && address(&b.base_url) == address(&base_url)
276 {
277 key = b.key.clone();
278 }
279 if key.is_empty() && provider.key == "yes" {
280 return Err(Error::settings(
281 format!("Enter your {} key", provider.name),
282 "assistant_key",
283 &[("provider", provider.name)],
284 ));
285 }
286 let settings = crate::obj! { "provider" => provider.id, "model" => model, "baseUrl" => base_url, "key" => key };
287 let sealed = seal(&settings.to_json(), self.secret());
288 self.store().set_setting("assistant", Some(&sealed)).await?;
289 Ok(())
290 }
291}