1
/*
2
 * This file is part of mailpot
3
 *
4
 * Copyright 2020 - Manos Pitsidianakis
5
 *
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU Affero General Public License as
8
 * published by the Free Software Foundation, either version 3 of the
9
 * License, or (at your option) any later version.
10
 *
11
 * This program is distributed in the hope that it will be useful,
12
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
 * GNU Affero General Public License for more details.
15
 *
16
 * You should have received a copy of the GNU Affero General Public License
17
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18
 */
19

            
20
use super::*;
21

            
22
/// Mailing list index.
23
pub async fn list(
24
    ListPath(id): ListPath,
25
    mut session: WritableSession,
26
    auth: AuthContext,
27
    State(state): State<Arc<AppState>>,
28
) -> Result<Html<String>, ResponseError> {
29
    let db = Connection::open_db(state.conf.clone())?;
30
    let Some(list) = (match id {
31
        ListPathIdentifier::Pk(id) => db.list(id)?,
32
        ListPathIdentifier::Id(id) => db.list_by_id(id)?,
33
    }) else {
34
        return Err(ResponseError::new(
35
            "List not found".to_string(),
36
            StatusCode::NOT_FOUND,
37
        ));
38
    };
39
    let post_policy = db.list_post_policy(list.pk)?;
40
    let subscription_policy = db.list_subscription_policy(list.pk)?;
41
    let months = db.months(list.pk)?;
42
    let user_context = auth
43
        .current_user
44
        .as_ref()
45
        .map(|user| db.list_subscription_by_address(list.pk, &user.address).ok());
46

            
47
    let posts = db.list_posts(list.pk, None)?;
48
    let mut hist = months
49
        .iter()
50
        .map(|m| (m.to_string(), [0usize; 31]))
51
        .collect::<HashMap<String, [usize; 31]>>();
52
    let posts_ctx = posts
53
        .iter()
54
        .map(|post| {
55
            //2019-07-14T14:21:02
56
            if let Some(day) = post.datetime.get(8..10).and_then(|d| d.parse::<u64>().ok()) {
57
                hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
58
            }
59
            let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
60
                .expect("Could not parse mail");
61
            let mut msg_id = &post.message_id[1..];
62
            msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
63
            let subject = envelope.subject();
64
            let mut subject_ref = subject.trim();
65
            if subject_ref.starts_with('[')
66
                && subject_ref[1..].starts_with(&list.id)
67
                && subject_ref[1 + list.id.len()..].starts_with(']')
68
            {
69
                subject_ref = subject_ref[2 + list.id.len()..].trim();
70
            }
71
            minijinja::context! {
72
                pk => post.pk,
73
                list => post.list,
74
                subject => subject_ref,
75
                address => post.address,
76
                message_id => msg_id,
77
                message => post.message,
78
                timestamp => post.timestamp,
79
                datetime => post.datetime,
80
                root_url_prefix => &state.root_url_prefix,
81
            }
82
        })
83
        .collect::<Vec<_>>();
84
    let crumbs = vec![
85
        Crumb {
86
            label: "Home".into(),
87
            url: "/".into(),
88
        },
89
        Crumb {
90
            label: list.name.clone().into(),
91
            url: ListPath(list.pk().into()).to_crumb(),
92
        },
93
    ];
94
    let context = minijinja::context! {
95
        title => state.site_title.as_ref(),
96
        page_title => &list.name,
97
        description => &list.description,
98
        post_policy => &post_policy,
99
        subscription_policy => &subscription_policy,
100
        preamble => true,
101
        months => &months,
102
        hists => &hist,
103
        posts => posts_ctx,
104
        body => &list.description.clone().unwrap_or_default(),
105
        root_url_prefix => &state.root_url_prefix,
106
        list => Value::from_object(MailingList::from(list)),
107
        current_user => auth.current_user,
108
        user_context => user_context,
109
        messages => session.drain_messages(),
110
        crumbs => crumbs,
111
    };
112
    Ok(Html(
113
        TEMPLATES.get_template("lists/list.html")?.render(context)?,
114
    ))
115
}
116

            
117
/// Mailing list post page.
118
pub async fn list_post(
119
    ListPostPath(id, msg_id): ListPostPath,
120
    mut session: WritableSession,
121
    auth: AuthContext,
122
    State(state): State<Arc<AppState>>,
123
) -> Result<Html<String>, ResponseError> {
124
    let db = Connection::open_db(state.conf.clone())?;
125
    let Some(list) = (match id {
126
        ListPathIdentifier::Pk(id) => db.list(id)?,
127
        ListPathIdentifier::Id(id) => db.list_by_id(id)?,
128
    }) else {
129
        return Err(ResponseError::new(
130
            "List not found".to_string(),
131
            StatusCode::NOT_FOUND,
132
        ));
133
    };
134
    let user_context = auth.current_user.as_ref().map(|user| {
135
        db.list_subscription_by_address(list.pk(), &user.address)
136
            .ok()
137
    });
138

            
139
    let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
140
        post
141
    } else {
142
        return Err(ResponseError::new(
143
            format!("Post with Message-ID {} not found", msg_id),
144
            StatusCode::NOT_FOUND,
145
        ));
146
    };
147
    let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
148
        .with_status(StatusCode::BAD_REQUEST)?;
149
    let body = envelope.body_bytes(post.message.as_slice());
150
    let body_text = body.text();
151
    let subject = envelope.subject();
152
    let mut subject_ref = subject.trim();
153
    if subject_ref.starts_with('[')
154
        && subject_ref[1..].starts_with(&list.id)
155
        && subject_ref[1 + list.id.len()..].starts_with(']')
156
    {
157
        subject_ref = subject_ref[2 + list.id.len()..].trim();
158
    }
159
    let crumbs = vec![
160
        Crumb {
161
            label: "Home".into(),
162
            url: "/".into(),
163
        },
164
        Crumb {
165
            label: list.name.clone().into(),
166
            url: ListPath(list.pk().into()).to_crumb(),
167
        },
168
        Crumb {
169
            label: format!("{} {msg_id}", subject_ref).into(),
170
            url: ListPostPath(list.pk().into(), msg_id.to_string()).to_crumb(),
171
        },
172
    ];
173
    let context = minijinja::context! {
174
        title => state.site_title.as_ref(),
175
        page_title => subject_ref,
176
        description => &list.description,
177
        list => Value::from_object(MailingList::from(list)),
178
        pk => post.pk,
179
        body => &body_text,
180
        from => &envelope.field_from_to_string(),
181
        date => &envelope.date_as_str(),
182
        to => &envelope.field_to_to_string(),
183
        subject => &envelope.subject(),
184
        trimmed_subject => subject_ref,
185
        in_reply_to => &envelope.in_reply_to_display().map(|r| r.to_string().as_str().strip_carets().to_string()),
186
        references => &envelope.references().into_iter().map(|m| m.to_string().as_str().strip_carets().to_string()).collect::<Vec<String>>(),
187
        message_id => msg_id,
188
        message => post.message,
189
        timestamp => post.timestamp,
190
        datetime => post.datetime,
191
        root_url_prefix => &state.root_url_prefix,
192
        current_user => auth.current_user,
193
        user_context => user_context,
194
        messages => session.drain_messages(),
195
        crumbs => crumbs,
196
    };
197
    Ok(Html(
198
        TEMPLATES.get_template("lists/post.html")?.render(context)?,
199
    ))
200
}
201

            
202
pub async fn list_edit(
203
    ListEditPath(id): ListEditPath,
204
    mut session: WritableSession,
205
    auth: AuthContext,
206
    State(state): State<Arc<AppState>>,
207
) -> Result<Html<String>, ResponseError> {
208
    let db = Connection::open_db(state.conf.clone())?;
209
    let Some(list) = (match id {
210
        ListPathIdentifier::Pk(id) => db.list(id)?,
211
        ListPathIdentifier::Id(id) => db.list_by_id(id)?,
212
    }) else {
213
        return Err(ResponseError::new(
214
            "Not found".to_string(),
215
            StatusCode::NOT_FOUND,
216
        ));
217
    };
218
    let list_owners = db.list_owners(list.pk)?;
219
    let user_address = &auth.current_user.as_ref().unwrap().address;
220
    if !list_owners.iter().any(|o| &o.address == user_address) {
221
        return Err(ResponseError::new(
222
            "Not found".to_string(),
223
            StatusCode::NOT_FOUND,
224
        ));
225
    };
226

            
227
    let post_policy = db.list_post_policy(list.pk)?;
228
    let subscription_policy = db.list_subscription_policy(list.pk)?;
229
    let post_count = {
230
        let mut stmt = db
231
            .connection
232
            .prepare("SELECT count(*) FROM post WHERE list = ?;")?;
233
        stmt.query_row([&list.pk], |row| {
234
            let count: usize = row.get(0)?;
235
            Ok(count)
236
        })
237
        .optional()?
238
        .unwrap_or(0)
239
    };
240
    let subs_count = {
241
        let mut stmt = db
242
            .connection
243
            .prepare("SELECT count(*) FROM subscription WHERE list = ?;")?;
244
        stmt.query_row([&list.pk], |row| {
245
            let count: usize = row.get(0)?;
246
            Ok(count)
247
        })
248
        .optional()?
249
        .unwrap_or(0)
250
    };
251
    let sub_requests_count = {
252
        let mut stmt = db.connection.prepare(
253
            "SELECT count(*) FROM candidate_subscription WHERE list = ? AND accepted IS NOT NULL;",
254
        )?;
255
        stmt.query_row([&list.pk], |row| {
256
            let count: usize = row.get(0)?;
257
            Ok(count)
258
        })
259
        .optional()?
260
        .unwrap_or(0)
261
    };
262

            
263
    let crumbs = vec![
264
        Crumb {
265
            label: "Home".into(),
266
            url: "/".into(),
267
        },
268
        Crumb {
269
            label: list.name.clone().into(),
270
            url: ListPath(list.pk().into()).to_crumb(),
271
        },
272
    ];
273
    let context = minijinja::context! {
274
        title => state.site_title.as_ref(),
275
        page_title => format!("Edit {} settings", list.name),
276
        description => &list.description,
277
        post_policy => &post_policy,
278
        subscription_policy => &subscription_policy,
279
        list_owners => list_owners,
280
        post_count => post_count,
281
        subs_count => subs_count,
282
        sub_requests_count => sub_requests_count,
283
        root_url_prefix => &state.root_url_prefix,
284
        list => Value::from_object(MailingList::from(list)),
285
        current_user => auth.current_user,
286
        messages => session.drain_messages(),
287
        crumbs => crumbs,
288
    };
289
    Ok(Html(
290
        TEMPLATES.get_template("lists/edit.html")?.render(context)?,
291
    ))
292
}
293

            
294
pub async fn list_edit_post(
295
    ListEditPath(id): ListEditPath,
296
    mut session: WritableSession,
297
    Extension(user): Extension<User>,
298
    Form(payload): Form<ChangeSetting>,
299
    State(state): State<Arc<AppState>>,
300
) -> Result<Redirect, ResponseError> {
301
    let db = Connection::open_db(state.conf.clone())?;
302
    let Some(list) = (match id {
303
        ListPathIdentifier::Pk(id) => db.list(id)?,
304
        ListPathIdentifier::Id(ref id) => db.list_by_id(id)?,
305
    }) else {
306
        return Err(ResponseError::new(
307
            "Not found".to_string(),
308
            StatusCode::NOT_FOUND,
309
        ));
310
    };
311
    let list_owners = db.list_owners(list.pk)?;
312
    let user_address = &user.address;
313
    if !list_owners.iter().any(|o| &o.address == user_address) {
314
        return Err(ResponseError::new(
315
            "Not found".to_string(),
316
            StatusCode::NOT_FOUND,
317
        ));
318
    };
319

            
320
    let mut db = db.trusted();
321
    match payload {
322
        ChangeSetting::PostPolicy {
323
            delete_post_policy: _,
324
            post_policy: val,
325
        } => {
326
            use PostPolicySettings::*;
327
            session.add_message(
328
                if let Err(err) = db.set_list_post_policy(mailpot::models::PostPolicy {
329
                    pk: -1,
330
                    list: list.pk,
331
                    announce_only: matches!(val, AnnounceOnly),
332
                    subscription_only: matches!(val, SubscriptionOnly),
333
                    approval_needed: matches!(val, ApprovalNeeded),
334
                    open: matches!(val, Open),
335
                    custom: matches!(val, Custom),
336
                }) {
337
                    Message {
338
                        message: err.to_string().into(),
339
                        level: Level::Error,
340
                    }
341
                } else {
342
                    Message {
343
                        message: "Post policy saved.".into(),
344
                        level: Level::Success,
345
                    }
346
                },
347
            )?;
348
        }
349
        ChangeSetting::SubscriptionPolicy {
350
            send_confirmation: BoolPOST(send_confirmation),
351
            subscription_policy: val,
352
        } => {
353
            use SubscriptionPolicySettings::*;
354
            session.add_message(
355
                if let Err(err) =
356
                    db.set_list_subscription_policy(mailpot::models::SubscriptionPolicy {
357
                        pk: -1,
358
                        list: list.pk,
359
                        send_confirmation,
360
                        open: matches!(val, Open),
361
                        manual: matches!(val, Manual),
362
                        request: matches!(val, Request),
363
                        custom: matches!(val, Custom),
364
                    })
365
                {
366
                    Message {
367
                        message: err.to_string().into(),
368
                        level: Level::Error,
369
                    }
370
                } else {
371
                    Message {
372
                        message: "Subscription policy saved.".into(),
373
                        level: Level::Success,
374
                    }
375
                },
376
            )?;
377
        }
378
        ChangeSetting::Metadata {
379
            name,
380
            id,
381
            address,
382
            description,
383
            owner_local_part,
384
            request_local_part,
385
            archive_url,
386
        } => {
387
            session.add_message(
388
                if let Err(err) =
389
                    db.update_list(mailpot::models::changesets::MailingListChangeset {
390
                        pk: list.pk,
391
                        name: Some(name),
392
                        id: Some(id),
393
                        address: Some(address),
394
                        description: description.map(|s| if s.is_empty() { None } else { Some(s) }),
395
                        owner_local_part: owner_local_part.map(|s| {
396
                            if s.is_empty() {
397
                                None
398
                            } else {
399
                                Some(s)
400
                            }
401
                        }),
402
                        request_local_part: request_local_part.map(|s| {
403
                            if s.is_empty() {
404
                                None
405
                            } else {
406
                                Some(s)
407
                            }
408
                        }),
409
                        archive_url: archive_url.map(|s| if s.is_empty() { None } else { Some(s) }),
410
                        ..Default::default()
411
                    })
412
                {
413
                    Message {
414
                        message: err.to_string().into(),
415
                        level: Level::Error,
416
                    }
417
                } else {
418
                    Message {
419
                        message: "List metadata saved.".into(),
420
                        level: Level::Success,
421
                    }
422
                },
423
            )?;
424
        }
425
    }
426

            
427
    Ok(Redirect::to(&format!(
428
        "{}{}",
429
        &state.root_url_prefix,
430
        ListEditPath(id).to_uri()
431
    )))
432
}
433

            
434
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
435
#[serde(tag = "type", rename_all = "kebab-case")]
436
pub enum ChangeSetting {
437
    PostPolicy {
438
        #[serde(rename = "delete-post-policy", default)]
439
        delete_post_policy: Option<String>,
440
        #[serde(rename = "post-policy")]
441
        post_policy: PostPolicySettings,
442
    },
443
    SubscriptionPolicy {
444
        #[serde(rename = "send-confirmation", default)]
445
        send_confirmation: BoolPOST,
446
        #[serde(rename = "subscription-policy")]
447
        subscription_policy: SubscriptionPolicySettings,
448
    },
449
    Metadata {
450
        name: String,
451
        id: String,
452
        #[serde(default)]
453
        address: String,
454
        #[serde(default)]
455
        description: Option<String>,
456
        #[serde(rename = "owner-local-part")]
457
        #[serde(default)]
458
        owner_local_part: Option<String>,
459
        #[serde(rename = "request-local-part")]
460
        #[serde(default)]
461
        request_local_part: Option<String>,
462
        #[serde(rename = "archive-url")]
463
        #[serde(default)]
464
        archive_url: Option<String>,
465
    },
466
}
467

            
468
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
469
#[serde(rename_all = "kebab-case")]
470
pub enum PostPolicySettings {
471
    AnnounceOnly,
472
    SubscriptionOnly,
473
    ApprovalNeeded,
474
    Open,
475
    Custom,
476
}
477

            
478
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
479
#[serde(rename_all = "kebab-case")]
480
pub enum SubscriptionPolicySettings {
481
    Open,
482
    Manual,
483
    Request,
484
    Custom,
485
}