Skip to main content

runlight/
runlight_settings.rs

1//! The Runlight's mail service, email reports, and the assistant's settings (runlight.ts).
2
3use 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
19/// An app mailer's own error as a MailError coded `mail_app_failed`, its words as the detail.
20fn 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    /// The mail Runlight sends with: a service from code, or as saved in the dashboard, or else the app's
31    /// own mailer, as service "app" with its `via`, with where it came from (`code`, `dashboard`, or `app`).
32    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            // A service no longer offered counts as none, so it is never sent through.
42            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    /// Saves the mail service from the dashboard. A secret field left blank keeps the saved value, so the
60    /// browser never needs to see it. `None` removes it.
61    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        // Only a service saved in the dashboard has secrets to keep, never the app's mailer.
70        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        // A blank secret keeps the saved one only while the connection is the same, so changing the host
88        // cannot send a saved password somewhere new.
89        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    /// Sends one email through the mail service, or the app's own mailer when no service is saved.
129    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                // The crate's own Error::Mail passes through as a bare MailError does.
142                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    /// Sends every report that is due: last week's on Monday from 8am, last month's on the 1st, in each
150    /// site's timezone. Safe to run often; each period goes out once. Gives how many went and failed.
151    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    /// Builds and sends one report. Also used by "Send a sample now".
187    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    /// The dashboard assistant's provider, model, and key, kept sealed like the mail keys.
219    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    /// Saves the assistant's settings; an empty key keeps the one saved for the same provider. `None` removes them.
233    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        // A saved key is kept only for the same service at the same address, so it is never sent somewhere new.
271        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}