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 chrono::TimeZone;
21
use indexmap::IndexMap;
22
use mailpot::models::Post;
23

            
24
use super::*;
25

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

            
51
    let posts = db.list_posts(list.pk, None)?;
52
    let post_map = posts
53
        .iter()
54
        .map(|p| (p.message_id.as_str(), p))
55
        .collect::<IndexMap<&str, &mailpot::models::DbVal<mailpot::models::Post>>>();
56
    let mut hist = months
57
        .iter()
58
        .map(|m| (m.to_string(), [0usize; 31]))
59
        .collect::<HashMap<String, [usize; 31]>>();
60
    let envelopes: Arc<std::sync::RwLock<HashMap<melib::EnvelopeHash, melib::Envelope>>> =
61
        Default::default();
62
    {
63
        let mut env_lock = envelopes.write().unwrap();
64

            
65
        for post in &posts {
66
            let Ok(mut envelope) = melib::Envelope::from_bytes(post.message.as_slice(), None)
67
            else {
68
                continue;
69
            };
70
            if envelope.message_id != post.message_id.as_str() {
71
                // If they don't match, the raw envelope doesn't contain a Message-ID and it was
72
                // randomly generated. So set the envelope's Message-ID to match the
73
                // post's, which is the "permanent" one since our source of truth is
74
                // the database.
75
                envelope.set_message_id(post.message_id.as_bytes());
76
            }
77
            env_lock.insert(envelope.hash(), envelope);
78
        }
79
    }
80
    let mut threads: melib::Threads = melib::Threads::new(posts.len());
81
    threads.amend(&envelopes);
82
    let roots = thread_roots(&envelopes, &threads);
83
    let posts_ctx = roots
84
        .into_iter()
85
        .filter_map(|(thread, length, _timestamp)| {
86
            let post = &post_map[&thread.message_id.as_str()];
87
            //2019-07-14T14:21:02
88
            if let Some(day) =
89
                chrono::DateTime::<chrono::FixedOffset>::parse_from_rfc2822(post.datetime.trim())
90
                    .ok()
91
                    .map(|d| d.day())
92
            {
93
                hist.get_mut(&post.month_year).unwrap()[day.saturating_sub(1) as usize] += 1;
94
            }
95
            let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None).ok()?;
96
            let mut msg_id = &post.message_id[1..];
97
            msg_id = &msg_id[..msg_id.len().saturating_sub(1)];
98
            let subject = envelope.subject();
99
            let mut subject_ref = subject.trim();
100
            if subject_ref.starts_with('[')
101
                && subject_ref[1..].starts_with(&list.id)
102
                && subject_ref[1 + list.id.len()..].starts_with(']')
103
            {
104
                subject_ref = subject_ref[2 + list.id.len()..].trim();
105
            }
106
            let ret = minijinja::context! {
107
                pk => post.pk,
108
                list => post.list,
109
                subject => subject_ref,
110
                address => post.address,
111
                message_id => msg_id,
112
                message => post.message,
113
                timestamp => post.timestamp,
114
                datetime => post.datetime,
115
                replies => length.saturating_sub(1),
116
                last_active => thread.datetime,
117
            };
118
            Some(ret)
119
        })
120
        .collect::<Vec<_>>();
121
    let crumbs = vec![
122
        Crumb {
123
            label: "Home".into(),
124
            url: "/".into(),
125
        },
126
        Crumb {
127
            label: list.name.clone().into(),
128
            url: ListPath(list.id.to_string().into()).to_crumb(),
129
        },
130
    ];
131
    let list_owners = db.list_owners(list.pk)?;
132
    let mut list_obj = MailingList::from(list.clone());
133
    list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
134
    let context = minijinja::context! {
135
        canonical_url => ListPath::from(&list).to_crumb(),
136
        page_title => &list.name,
137
        description => &list.description,
138
        post_policy,
139
        subscription_policy,
140
        preamble => true,
141
        months,
142
        hists => &hist,
143
        posts => posts_ctx,
144
        list => Value::from_object(list_obj),
145
        current_user => auth.current_user,
146
        user_context,
147
        messages => session.drain_messages(),
148
        crumbs,
149
    };
150
    Ok(Html(
151
        TEMPLATES.get_template("lists/list.html")?.render(context)?,
152
    ))
153
4
}
154

            
155
/// Mailing list post page.
156
1
pub async fn list_post(
157
1
    ListPostPath(id, msg_id): ListPostPath,
158
    mut session: WritableSession,
159
    auth: AuthContext,
160
    State(state): State<Arc<AppState>>,
161
1
) -> Result<Html<String>, ResponseError> {
162
    let db = Connection::open_db(state.conf.clone())?.trusted();
163
    let Some(list) = (match id {
164
        ListPathIdentifier::Pk(id) => db.list(id)?,
165
        ListPathIdentifier::Id(id) => db.list_by_id(id)?,
166
    }) else {
167
        return Err(ResponseError::new(
168
            "List not found".to_string(),
169
            StatusCode::NOT_FOUND,
170
        ));
171
    };
172
    let user_context = auth.current_user.as_ref().map(|user| {
173
        db.list_subscription_by_address(list.pk(), &user.address)
174
            .ok()
175
    });
176

            
177
    let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
178
        post
179
    } else {
180
        return Err(ResponseError::new(
181
            format!("Post with Message-ID {} not found", msg_id),
182
            StatusCode::NOT_FOUND,
183
        ));
184
    };
185
    let thread: Vec<(i64, DbVal<Post>, String, String)> = {
186
        let thread: Vec<(i64, DbVal<Post>)> = db.list_thread(list.pk, &post.message_id)?;
187

            
188
        thread
189
            .into_iter()
190
            .map(|(depth, p)| {
191
                let envelope = melib::Envelope::from_bytes(p.message.as_slice(), None).unwrap();
192
                let body = envelope.body_bytes(p.message.as_slice());
193
                let body_text = body.text();
194
                let date = envelope.date_as_str().to_string();
195
                (depth, p, body_text, date)
196
            })
197
            .collect()
198
    };
199
    let envelope = melib::Envelope::from_bytes(post.message.as_slice(), None)
200
        .with_status(StatusCode::BAD_REQUEST)?;
201
    let body = envelope.body_bytes(post.message.as_slice());
202
    let body_text = body.text();
203
    let subject = envelope.subject();
204
    let mut subject_ref = subject.trim();
205
    if subject_ref.starts_with('[')
206
        && subject_ref[1..].starts_with(&list.id)
207
        && subject_ref[1 + list.id.len()..].starts_with(']')
208
    {
209
        subject_ref = subject_ref[2 + list.id.len()..].trim();
210
    }
211
    let crumbs = vec![
212
        Crumb {
213
            label: "Home".into(),
214
            url: "/".into(),
215
        },
216
        Crumb {
217
            label: list.name.clone().into(),
218
            url: ListPath(list.id.to_string().into()).to_crumb(),
219
        },
220
        Crumb {
221
            label: format!("{} {msg_id}", subject_ref).into(),
222
            url: ListPostPath(list.id.to_string().into(), msg_id.to_string()).to_crumb(),
223
        },
224
    ];
225

            
226
    let list_owners = db.list_owners(list.pk)?;
227
    let mut list_obj = MailingList::from(list.clone());
228
    list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
229

            
230
    let context = minijinja::context! {
231
        canonical_url => ListPostPath(ListPathIdentifier::from(list.id.clone()), msg_id.to_string()).to_crumb(),
232
        page_title => subject_ref,
233
        description => &list.description,
234
        list => Value::from_object(list_obj),
235
        pk => post.pk,
236
        body => &body_text,
237
        from => &envelope.field_from_to_string(),
238
        date => &envelope.date_as_str(),
239
        to => &envelope.field_to_to_string(),
240
        subject => &envelope.subject(),
241
        trimmed_subject => subject_ref,
242
        in_reply_to => &envelope.in_reply_to_display().map(|r| r.to_string().as_str().strip_carets().to_string()),
243
        references => &envelope.references().into_iter().map(|m| m.to_string().as_str().strip_carets().to_string()).collect::<Vec<String>>(),
244
        message_id => msg_id,
245
        message => post.message,
246
        timestamp => post.timestamp,
247
        datetime => post.datetime,
248
        thread => thread,
249
        current_user => auth.current_user,
250
        user_context => user_context,
251
        messages => session.drain_messages(),
252
        crumbs => crumbs,
253
    };
254
    Ok(Html(
255
        TEMPLATES.get_template("lists/post.html")?.render(context)?,
256
    ))
257
2
}
258

            
259
1
pub async fn list_edit(
260
1
    ListEditPath(id): ListEditPath,
261
    mut session: WritableSession,
262
    auth: AuthContext,
263
    State(state): State<Arc<AppState>>,
264
1
) -> Result<Html<String>, ResponseError> {
265
    let db = Connection::open_db(state.conf.clone())?;
266
    let Some(list) = (match id {
267
        ListPathIdentifier::Pk(id) => db.list(id)?,
268
        ListPathIdentifier::Id(id) => db.list_by_id(id)?,
269
    }) else {
270
        return Err(ResponseError::new(
271
            "Not found".to_string(),
272
            StatusCode::NOT_FOUND,
273
        ));
274
    };
275
    let list_owners = db.list_owners(list.pk)?;
276
    let user_address = &auth.current_user.as_ref().unwrap().address;
277
    if !list_owners.iter().any(|o| &o.address == user_address) {
278
        return Err(ResponseError::new(
279
            "Not found".to_string(),
280
            StatusCode::NOT_FOUND,
281
        ));
282
    };
283

            
284
    let post_policy = db.list_post_policy(list.pk)?;
285
    let subscription_policy = db.list_subscription_policy(list.pk)?;
286
    let post_count = {
287
        let mut stmt = db
288
            .connection
289
            .prepare("SELECT count(*) FROM post WHERE list = ?;")?;
290
        stmt.query_row([&list.pk], |row| {
291
            let count: usize = row.get(0)?;
292
            Ok(count)
293
        })
294
        .optional()?
295
        .unwrap_or(0)
296
    };
297
    let subs_count = {
298
        let mut stmt = db
299
            .connection
300
            .prepare("SELECT count(*) FROM subscription WHERE list = ?;")?;
301
        stmt.query_row([&list.pk], |row| {
302
            let count: usize = row.get(0)?;
303
            Ok(count)
304
        })
305
        .optional()?
306
        .unwrap_or(0)
307
    };
308
    let sub_requests_count = {
309
        let mut stmt = db.connection.prepare(
310
            "SELECT count(*) FROM candidate_subscription WHERE list = ? AND accepted IS NULL;",
311
        )?;
312
        stmt.query_row([&list.pk], |row| {
313
            let count: usize = row.get(0)?;
314
            Ok(count)
315
        })
316
        .optional()?
317
        .unwrap_or(0)
318
    };
319

            
320
    let crumbs = vec![
321
        Crumb {
322
            label: "Home".into(),
323
            url: "/".into(),
324
        },
325
        Crumb {
326
            label: list.name.clone().into(),
327
            url: ListPath(list.id.to_string().into()).to_crumb(),
328
        },
329
        Crumb {
330
            label: format!("Edit {}", list.name).into(),
331
            url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
332
        },
333
    ];
334
    let list_owners = db.list_owners(list.pk)?;
335
    let mut list_obj = MailingList::from(list.clone());
336
    list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
337
    let context = minijinja::context! {
338
        canonical_url => ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
339
        page_title => format!("Edit {} settings", list.name),
340
        description => &list.description,
341
        post_policy,
342
        subscription_policy,
343
        list_owners,
344
        post_count,
345
        subs_count,
346
        sub_requests_count,
347
        list => Value::from_object(list_obj),
348
        current_user => auth.current_user,
349
        messages => session.drain_messages(),
350
        crumbs,
351
    };
352
    Ok(Html(
353
        TEMPLATES.get_template("lists/edit.html")?.render(context)?,
354
    ))
355
2
}
356

            
357
#[allow(non_snake_case)]
358
3
pub async fn list_edit_POST(
359
3
    ListEditPath(id): ListEditPath,
360
    mut session: WritableSession,
361
    Extension(user): Extension<User>,
362
    Form(payload): Form<ChangeSetting>,
363
    State(state): State<Arc<AppState>>,
364
3
) -> Result<Redirect, ResponseError> {
365
    let db = Connection::open_db(state.conf.clone())?;
366
    let Some(list) = (match id {
367
        ListPathIdentifier::Pk(id) => db.list(id)?,
368
        ListPathIdentifier::Id(ref id) => db.list_by_id(id)?,
369
    }) else {
370
        return Err(ResponseError::new(
371
            "Not found".to_string(),
372
            StatusCode::NOT_FOUND,
373
        ));
374
    };
375
    let list_owners = db.list_owners(list.pk)?;
376
    let user_address = &user.address;
377
    if !list_owners.iter().any(|o| &o.address == user_address) {
378
        return Err(ResponseError::new(
379
            "Not found".to_string(),
380
            StatusCode::NOT_FOUND,
381
        ));
382
    };
383

            
384
    let db = db.trusted();
385
    match payload {
386
        ChangeSetting::PostPolicy {
387
            delete_post_policy: _,
388
            post_policy: val,
389
        } => {
390
            use PostPolicySettings::*;
391
            session.add_message(
392
                if let Err(err) = db.set_list_post_policy(mailpot::models::PostPolicy {
393
                    pk: -1,
394
                    list: list.pk,
395
                    announce_only: matches!(val, AnnounceOnly),
396
                    subscription_only: matches!(val, SubscriptionOnly),
397
                    approval_needed: matches!(val, ApprovalNeeded),
398
                    open: matches!(val, Open),
399
                    custom: matches!(val, Custom),
400
                }) {
401
                    Message {
402
                        message: err.to_string().into(),
403
                        level: Level::Error,
404
                    }
405
                } else {
406
                    Message {
407
                        message: "Post policy saved.".into(),
408
                        level: Level::Success,
409
                    }
410
                },
411
            )?;
412
        }
413
        ChangeSetting::SubscriptionPolicy {
414
            send_confirmation: BoolPOST(send_confirmation),
415
            subscription_policy: val,
416
        } => {
417
            use SubscriptionPolicySettings::*;
418
            session.add_message(
419
                if let Err(err) =
420
                    db.set_list_subscription_policy(mailpot::models::SubscriptionPolicy {
421
                        pk: -1,
422
                        list: list.pk,
423
                        send_confirmation,
424
                        open: matches!(val, Open),
425
                        manual: matches!(val, Manual),
426
                        request: matches!(val, Request),
427
                        custom: matches!(val, Custom),
428
                    })
429
                {
430
                    Message {
431
                        message: err.to_string().into(),
432
                        level: Level::Error,
433
                    }
434
                } else {
435
                    Message {
436
                        message: "Subscription policy saved.".into(),
437
                        level: Level::Success,
438
                    }
439
                },
440
            )?;
441
        }
442
        ChangeSetting::Metadata {
443
            name,
444
            id,
445
            address,
446
            description,
447
            owner_local_part,
448
            request_local_part,
449
            archive_url,
450
        } => {
451
            session.add_message(
452
                if let Err(err) =
453
                    db.update_list(mailpot::models::changesets::MailingListChangeset {
454
                        pk: list.pk,
455
                        name: Some(name),
456
                        id: Some(id),
457
                        address: Some(address),
458
                        description: description.map(|s| if s.is_empty() { None } else { Some(s) }),
459
                        owner_local_part: owner_local_part.map(|s| {
460
                            if s.is_empty() {
461
                                None
462
                            } else {
463
                                Some(s)
464
                            }
465
                        }),
466
                        request_local_part: request_local_part.map(|s| {
467
                            if s.is_empty() {
468
                                None
469
                            } else {
470
                                Some(s)
471
                            }
472
                        }),
473
                        archive_url: archive_url.map(|s| if s.is_empty() { None } else { Some(s) }),
474
                        ..Default::default()
475
                    })
476
                {
477
                    Message {
478
                        message: err.to_string().into(),
479
                        level: Level::Error,
480
                    }
481
                } else {
482
                    Message {
483
                        message: "List metadata saved.".into(),
484
                        level: Level::Success,
485
                    }
486
                },
487
            )?;
488
        }
489
        ChangeSetting::AcceptSubscriptionRequest { pk: IntPOST(pk) } => {
490
            session.add_message(match db.accept_candidate_subscription(pk) {
491
                Ok(subscription) => Message {
492
                    message: format!("Added: {subscription:#?}").into(),
493
                    level: Level::Success,
494
                },
495
                Err(err) => Message {
496
                    message: format!("Could not accept subscription request! Reason: {err}").into(),
497
                    level: Level::Error,
498
                },
499
            })?;
500
        }
501
    }
502

            
503
    Ok(Redirect::to(&format!(
504
        "{}{}",
505
        &state.root_url_prefix,
506
        ListEditPath(id).to_uri()
507
    )))
508
6
}
509

            
510
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
511
#[serde(tag = "type", rename_all = "kebab-case")]
512
pub enum ChangeSetting {
513
    PostPolicy {
514
        #[serde(rename = "delete-post-policy", default)]
515
        delete_post_policy: Option<String>,
516
        #[serde(rename = "post-policy")]
517
        post_policy: PostPolicySettings,
518
    },
519
    SubscriptionPolicy {
520
        #[serde(rename = "send-confirmation", default)]
521
        send_confirmation: BoolPOST,
522
        #[serde(rename = "subscription-policy")]
523
        subscription_policy: SubscriptionPolicySettings,
524
    },
525
    Metadata {
526
        name: String,
527
        id: String,
528
        #[serde(default)]
529
        address: String,
530
        #[serde(default)]
531
        description: Option<String>,
532
        #[serde(rename = "owner-local-part")]
533
        #[serde(default)]
534
        owner_local_part: Option<String>,
535
        #[serde(rename = "request-local-part")]
536
        #[serde(default)]
537
        request_local_part: Option<String>,
538
        #[serde(rename = "archive-url")]
539
        #[serde(default)]
540
        archive_url: Option<String>,
541
    },
542
    AcceptSubscriptionRequest {
543
        pk: IntPOST,
544
    },
545
}
546

            
547
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
548
#[serde(rename_all = "kebab-case")]
549
pub enum PostPolicySettings {
550
    AnnounceOnly,
551
    SubscriptionOnly,
552
    ApprovalNeeded,
553
    Open,
554
    Custom,
555
}
556

            
557
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
558
#[serde(rename_all = "kebab-case")]
559
pub enum SubscriptionPolicySettings {
560
    Open,
561
    Manual,
562
    Request,
563
    Custom,
564
}
565

            
566
/// Raw post page.
567
1
pub async fn list_post_raw(
568
1
    ListPostRawPath(id, msg_id): ListPostRawPath,
569
    State(state): State<Arc<AppState>>,
570
1
) -> Result<String, ResponseError> {
571
    let db = Connection::open_db(state.conf.clone())?.trusted();
572
    let Some(list) = (match id {
573
        ListPathIdentifier::Pk(id) => db.list(id)?,
574
        ListPathIdentifier::Id(id) => db.list_by_id(id)?,
575
    }) else {
576
        return Err(ResponseError::new(
577
            "List not found".to_string(),
578
            StatusCode::NOT_FOUND,
579
        ));
580
    };
581

            
582
    let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
583
        post
584
    } else {
585
        return Err(ResponseError::new(
586
            format!("Post with Message-ID {} not found", msg_id),
587
            StatusCode::NOT_FOUND,
588
        ));
589
    };
590
    Ok(String::from_utf8_lossy(&post.message).to_string())
591
2
}
592

            
593
/// .eml post page.
594
1
pub async fn list_post_eml(
595
1
    ListPostEmlPath(id, msg_id): ListPostEmlPath,
596
    State(state): State<Arc<AppState>>,
597
1
) -> Result<impl IntoResponse, ResponseError> {
598
    let db = Connection::open_db(state.conf.clone())?.trusted();
599
    let Some(list) = (match id {
600
        ListPathIdentifier::Pk(id) => db.list(id)?,
601
        ListPathIdentifier::Id(id) => db.list_by_id(id)?,
602
    }) else {
603
        return Err(ResponseError::new(
604
            "List not found".to_string(),
605
            StatusCode::NOT_FOUND,
606
        ));
607
    };
608

            
609
    let post = if let Some(post) = db.list_post_by_message_id(list.pk, &msg_id)? {
610
        post
611
    } else {
612
        return Err(ResponseError::new(
613
            format!("Post with Message-ID {} not found", msg_id),
614
            StatusCode::NOT_FOUND,
615
        ));
616
    };
617
    let mut response = post.into_inner().message.into_response();
618
    response.headers_mut().insert(
619
        http::header::CONTENT_TYPE,
620
        http::HeaderValue::from_static("application/octet-stream"),
621
    );
622
    response.headers_mut().insert(
623
        http::header::CONTENT_DISPOSITION,
624
        http::HeaderValue::try_from(format!(
625
            "attachment; filename=\"{}.eml\"",
626
            msg_id.trim().strip_carets()
627
        ))
628
        .unwrap(),
629
    );
630

            
631
    Ok(response)
632
2
}
633

            
634
pub async fn list_subscribers(
635
    ListEditSubscribersPath(id): ListEditSubscribersPath,
636
    mut session: WritableSession,
637
    auth: AuthContext,
638
    State(state): State<Arc<AppState>>,
639
) -> Result<Html<String>, ResponseError> {
640
    let db = Connection::open_db(state.conf.clone())?;
641
    let Some(list) = (match id {
642
        ListPathIdentifier::Pk(id) => db.list(id)?,
643
        ListPathIdentifier::Id(id) => db.list_by_id(id)?,
644
    }) else {
645
        return Err(ResponseError::new(
646
            "Not found".to_string(),
647
            StatusCode::NOT_FOUND,
648
        ));
649
    };
650
    let list_owners = db.list_owners(list.pk)?;
651
    let user_address = &auth.current_user.as_ref().unwrap().address;
652
    if !list_owners.iter().any(|o| &o.address == user_address) {
653
        return Err(ResponseError::new(
654
            "Not found".to_string(),
655
            StatusCode::NOT_FOUND,
656
        ));
657
    };
658

            
659
    let subs = {
660
        let mut stmt = db
661
            .connection
662
            .prepare("SELECT * FROM subscription WHERE list = ?;")?;
663
        let iter = stmt.query_map([&list.pk], |row| {
664
            let address: String = row.get("address")?;
665
            let name: Option<String> = row.get("name")?;
666
            let enabled: bool = row.get("enabled")?;
667
            let verified: bool = row.get("verified")?;
668
            let digest: bool = row.get("digest")?;
669
            let hide_address: bool = row.get("hide_address")?;
670
            let receive_duplicates: bool = row.get("receive_duplicates")?;
671
            let receive_own_posts: bool = row.get("receive_own_posts")?;
672
            let receive_confirmation: bool = row.get("receive_confirmation")?;
673
            //let last_digest: i64 = row.get("last_digest")?;
674
            let created: i64 = row.get("created")?;
675
            let last_modified: i64 = row.get("last_modified")?;
676
            Ok(minijinja::context! {
677
                address,
678
                name,
679
                enabled,
680
                verified,
681
                digest,
682
                hide_address,
683
                receive_duplicates,
684
                receive_own_posts,
685
                receive_confirmation,
686
                //last_digest => chrono::Utc.timestamp_opt(last_digest, 0).unwrap().to_string(),
687
                created => chrono::Utc.timestamp_opt(created, 0).unwrap().to_string(),
688
                last_modified => chrono::Utc.timestamp_opt(last_modified, 0).unwrap().to_string(),
689
            })
690
        })?;
691
        let mut ret = vec![];
692
        for el in iter {
693
            let el = el?;
694
            ret.push(el);
695
        }
696
        ret
697
    };
698

            
699
    let crumbs = vec![
700
        Crumb {
701
            label: "Home".into(),
702
            url: "/".into(),
703
        },
704
        Crumb {
705
            label: list.name.clone().into(),
706
            url: ListPath(list.id.to_string().into()).to_crumb(),
707
        },
708
        Crumb {
709
            label: format!("Edit {}", list.name).into(),
710
            url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
711
        },
712
        Crumb {
713
            label: format!("Subscribers of {}", list.name).into(),
714
            url: ListEditSubscribersPath(list.id.to_string().into()).to_crumb(),
715
        },
716
    ];
717
    let list_owners = db.list_owners(list.pk)?;
718
    let mut list_obj = MailingList::from(list.clone());
719
    list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
720
    let context = minijinja::context! {
721
        canonical_url => ListEditSubscribersPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
722
        page_title => format!("Subscribers of {}", list.name),
723
        subs,
724
        list => Value::from_object(list_obj),
725
        current_user => auth.current_user,
726
        messages => session.drain_messages(),
727
        crumbs,
728
    };
729
    Ok(Html(
730
        TEMPLATES.get_template("lists/subs.html")?.render(context)?,
731
    ))
732
}
733

            
734
pub async fn list_candidates(
735
    ListEditCandidatesPath(id): ListEditCandidatesPath,
736
    mut session: WritableSession,
737
    auth: AuthContext,
738
    State(state): State<Arc<AppState>>,
739
) -> Result<Html<String>, ResponseError> {
740
    let db = Connection::open_db(state.conf.clone())?;
741
    let Some(list) = (match id {
742
        ListPathIdentifier::Pk(id) => db.list(id)?,
743
        ListPathIdentifier::Id(id) => db.list_by_id(id)?,
744
    }) else {
745
        return Err(ResponseError::new(
746
            "Not found".to_string(),
747
            StatusCode::NOT_FOUND,
748
        ));
749
    };
750
    let list_owners = db.list_owners(list.pk)?;
751
    let user_address = &auth.current_user.as_ref().unwrap().address;
752
    if !list_owners.iter().any(|o| &o.address == user_address) {
753
        return Err(ResponseError::new(
754
            "Not found".to_string(),
755
            StatusCode::NOT_FOUND,
756
        ));
757
    };
758

            
759
    let subs = {
760
        let mut stmt = db
761
            .connection
762
            .prepare("SELECT * FROM candidate_subscription WHERE list = ?;")?;
763
        let iter = stmt.query_map([&list.pk], |row| {
764
            let pk: i64 = row.get("pk")?;
765
            let address: String = row.get("address")?;
766
            let name: Option<String> = row.get("name")?;
767
            let accepted: Option<i64> = row.get("accepted")?;
768
            let created: i64 = row.get("created")?;
769
            let last_modified: i64 = row.get("last_modified")?;
770
            Ok(minijinja::context! {
771
                pk,
772
                address,
773
                name,
774
                accepted => accepted.is_some(),
775
                created => chrono::Utc.timestamp_opt(created, 0).unwrap().to_string(),
776
                last_modified => chrono::Utc.timestamp_opt(last_modified, 0).unwrap().to_string(),
777
            })
778
        })?;
779
        let mut ret = vec![];
780
        for el in iter {
781
            let el = el?;
782
            ret.push(el);
783
        }
784
        ret
785
    };
786

            
787
    let crumbs = vec![
788
        Crumb {
789
            label: "Home".into(),
790
            url: "/".into(),
791
        },
792
        Crumb {
793
            label: list.name.clone().into(),
794
            url: ListPath(list.id.to_string().into()).to_crumb(),
795
        },
796
        Crumb {
797
            label: format!("Edit {}", list.name).into(),
798
            url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
799
        },
800
        Crumb {
801
            label: format!("Requests of {}", list.name).into(),
802
            url: ListEditCandidatesPath(list.id.to_string().into()).to_crumb(),
803
        },
804
    ];
805
    let mut list_obj: MailingList = MailingList::from(list.clone());
806
    list_obj.set_safety(list_owners.as_slice(), &state.conf.administrators);
807
    let context = minijinja::context! {
808
        canonical_url => ListEditCandidatesPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
809
        page_title => format!("Requests of {}", list.name),
810
        subs,
811
        list => Value::from_object(list_obj),
812
        current_user => auth.current_user,
813
        messages => session.drain_messages(),
814
        crumbs,
815
    };
816
    Ok(Html(
817
        TEMPLATES
818
            .get_template("lists/sub-requests.html")?
819
            .render(context)?,
820
    ))
821
}