Logos resolve domain, then tenant, then server-wide, then the built-in, with subdomains finding their domain. GET /logo serves a data-URL image, redirects to a URL logo without fetching it, sandboxes SVG, and answers 404 when no custom logo applies. Emails embed the first PNG, JPEG or GIF logo. Logo and template writes are checked; stored templates are read at send time, always escaped, and fall back to the built-in with a build warning when they don't parse. The RSVP page is served byte for byte with a CSP and no-referrer. The sign-in and RSVP pages load the logo through an image element. MT-22's session logo follows the chain to the server-wide logo. Acceptance tests 1 to 17; test 18 written as the ignored branding_compat.
679 lines
22 KiB
Rust
679 lines
22 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
use calcard::icalendar::{
|
|
ArchivedICalendarParameterName, ArchivedICalendarProperty, ICalendarProperty,
|
|
};
|
|
use common::{
|
|
DEFAULT_LOGO_BASE64, Server,
|
|
auth::{AccountInfo, BuildAccessToken},
|
|
config::groupware::CalendarTemplateVariable,
|
|
ipc::{CalendarAlert, PushNotification},
|
|
network::{ServerInstance, stream::NullIo},
|
|
};
|
|
use groupware::{
|
|
calendar::{ArchivedCalendarEvent, CalendarEvent},
|
|
scheduling::{
|
|
ItipTime, ItipValue,
|
|
format::{DateStyle, TextFormatter, hyperlink},
|
|
},
|
|
strip_mailto_scheme,
|
|
};
|
|
use mail_builder::{
|
|
MessageBuilder,
|
|
headers::{HeaderType, content_type::ContentType},
|
|
mime::{BodyPart, MimePart},
|
|
};
|
|
use mail_parser::decoders::html::html_to_text;
|
|
use registry::{
|
|
schema::{
|
|
enums::Permission,
|
|
structs::{TaskCalendarAlarmEmail, TaskCalendarAlarmNotification},
|
|
},
|
|
types::EnumImpl,
|
|
};
|
|
use smtp::core::{Session, SessionData};
|
|
use smtp_proto::{MailFrom, RcptTo};
|
|
use std::{sync::Arc, time::Duration};
|
|
use store::{
|
|
ValueKey,
|
|
write::{AlignedBytes, Archive, now},
|
|
};
|
|
use trc::{AddContext, TaskManagerEvent};
|
|
use types::collection::Collection;
|
|
use utils::{sanitize_email, template::Variables};
|
|
|
|
use crate::task_manager::TaskResult;
|
|
|
|
pub(crate) trait SendAlarmTask: Sync + Send {
|
|
fn send_display_alarm(
|
|
&self,
|
|
task: &TaskCalendarAlarmNotification,
|
|
) -> impl Future<Output = TaskResult> + Send;
|
|
|
|
fn send_email_alarm(
|
|
&self,
|
|
task: &TaskCalendarAlarmEmail,
|
|
server_instance: Arc<ServerInstance>,
|
|
) -> impl Future<Output = TaskResult> + Send;
|
|
}
|
|
|
|
impl SendAlarmTask for Server {
|
|
async fn send_display_alarm(&self, task: &TaskCalendarAlarmNotification) -> TaskResult {
|
|
match send_display_alarm(self, task).await {
|
|
Ok(result) => result,
|
|
Err(err) => {
|
|
let result = TaskResult::temporary(err.to_string());
|
|
trc::error!(
|
|
err.account_id(task.account_id.document_id())
|
|
.document_id(task.document_id.document_id())
|
|
.caused_by(trc::location!())
|
|
.details("Failed to process e-mail alarm")
|
|
);
|
|
result
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn send_email_alarm(
|
|
&self,
|
|
task: &TaskCalendarAlarmEmail,
|
|
server_instance: Arc<ServerInstance>,
|
|
) -> TaskResult {
|
|
match send_email_alarm(self, task, server_instance).await {
|
|
Ok(result) => result,
|
|
Err(err) => {
|
|
let result = TaskResult::temporary(err.to_string());
|
|
trc::error!(
|
|
err.account_id(task.account_id.document_id())
|
|
.document_id(task.document_id.document_id())
|
|
.caused_by(trc::location!())
|
|
.details("Failed to process e-mail alarm")
|
|
);
|
|
result
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn send_email_alarm(
|
|
server: &Server,
|
|
task: &TaskCalendarAlarmEmail,
|
|
server_instance: Arc<ServerInstance>,
|
|
) -> trc::Result<TaskResult> {
|
|
// Obtain access token
|
|
let account_id = task.account_id.document_id();
|
|
let document_id = task.document_id.document_id();
|
|
let access_token = server
|
|
.access_token(account_id)
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
.build();
|
|
|
|
if !access_token.has_permission(Permission::CalendarAlarmsSend) {
|
|
trc::event!(
|
|
Calendar(trc::CalendarEvent::AlarmSkipped),
|
|
Reason = "Account does not have permission to send calendar alarms",
|
|
AccountId = account_id,
|
|
DocumentId = document_id,
|
|
);
|
|
return Ok(TaskResult::Success(vec![]));
|
|
}
|
|
let account_info = server
|
|
.account_info(account_id)
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
|
|
if account_info.name().is_empty() {
|
|
trc::event!(
|
|
Calendar(trc::CalendarEvent::AlarmFailed),
|
|
Reason = "Account does not have any email addresses",
|
|
AccountId = account_id,
|
|
DocumentId = document_id,
|
|
);
|
|
return Ok(TaskResult::Success(vec![]));
|
|
}
|
|
|
|
// Fetch event
|
|
let Some(event_) = server
|
|
.store()
|
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
|
account_id,
|
|
Collection::CalendarEvent,
|
|
document_id,
|
|
))
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
else {
|
|
trc::event!(
|
|
TaskManager(TaskManagerEvent::MetadataNotFound),
|
|
Details = "Calendar Event metadata not found",
|
|
AccountId = account_id,
|
|
DocumentId = document_id,
|
|
);
|
|
|
|
return Ok(TaskResult::Success(vec![]));
|
|
};
|
|
|
|
// Unarchive event
|
|
let event = event_
|
|
.unarchive::<CalendarEvent>()
|
|
.caused_by(trc::location!())?;
|
|
|
|
// Build message body
|
|
let account_main_email = account_info.name();
|
|
let account_main_domain = account_main_email.rsplit('@').next().unwrap_or("localhost");
|
|
let logo_cid = format!("logo.{}@{account_main_domain}", now());
|
|
let Some(tpl) = build_template(server, &account_info, task, event, &logo_cid).await? else {
|
|
return Ok(TaskResult::Success(vec![]));
|
|
};
|
|
let txt_body = html_to_text(&tpl.body);
|
|
|
|
// Obtain logo image
|
|
let logo = match server.logo_resource(account_main_domain).await {
|
|
Ok(logo) => logo,
|
|
Err(err) => {
|
|
trc::error!(
|
|
err.caused_by(trc::location!())
|
|
.details("Failed to fetch logo image")
|
|
);
|
|
None
|
|
}
|
|
};
|
|
let logo = if let Some(logo) = &logo {
|
|
MimePart::new(
|
|
ContentType::new(logo.content_type.as_ref()),
|
|
BodyPart::Binary(logo.contents.as_slice().into()),
|
|
)
|
|
} else {
|
|
MimePart::new(
|
|
ContentType::new("image/png"),
|
|
BodyPart::Binary(DEFAULT_LOGO_BASE64.as_bytes().into()),
|
|
)
|
|
.transfer_encoding("base64")
|
|
}
|
|
.inline()
|
|
.cid(&logo_cid);
|
|
|
|
// Build message
|
|
let mail_from = if let Some(from_email) = &server.core.groupware.alarms_from_email {
|
|
from_email.to_string()
|
|
} else {
|
|
format!("calendar-notification@{account_main_domain}")
|
|
};
|
|
let message = MessageBuilder::new()
|
|
.from((
|
|
server.core.groupware.alarms_from_name.as_str(),
|
|
mail_from.as_str(),
|
|
))
|
|
.header("To", HeaderType::Text(tpl.to.as_str().into()))
|
|
.header("Auto-Submitted", HeaderType::Text("auto-generated".into()))
|
|
.header("Reply-To", HeaderType::Text(account_main_email.into()))
|
|
.message_id(server.core.network.message_id())
|
|
.subject(tpl.subject)
|
|
.body(MimePart::new(
|
|
ContentType::new("multipart/related"),
|
|
BodyPart::Multipart(vec![
|
|
MimePart::new(
|
|
ContentType::new("multipart/alternative"),
|
|
BodyPart::Multipart(vec![
|
|
MimePart::new(
|
|
ContentType::new("text/plain"),
|
|
BodyPart::Text(txt_body.into()),
|
|
),
|
|
MimePart::new(
|
|
ContentType::new("text/html"),
|
|
BodyPart::Text(tpl.body.into()),
|
|
),
|
|
]),
|
|
),
|
|
logo,
|
|
]),
|
|
))
|
|
.write_to_vec()
|
|
.unwrap_or_default();
|
|
|
|
// Send message
|
|
let server_ = server.clone();
|
|
let mail_from = account_main_email.to_string();
|
|
let to = tpl.to;
|
|
let result = tokio::spawn(async move {
|
|
let mut session = Session::<NullIo>::local(
|
|
server_,
|
|
server_instance,
|
|
SessionData::local(account_info, None, vec![], vec![], 0),
|
|
);
|
|
|
|
// MAIL FROM
|
|
let _ = session
|
|
.handle_mail_from(MailFrom {
|
|
address: mail_from.into(),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
if let Some(error) = session.has_failed() {
|
|
return Err(format!("Server rejected MAIL-FROM: {}", error.trim()));
|
|
}
|
|
|
|
// RCPT TO
|
|
session.params.rcpt_errors_wait = Duration::from_secs(0);
|
|
let _ = session
|
|
.handle_rcpt_to(RcptTo {
|
|
address: to.into(),
|
|
..Default::default()
|
|
})
|
|
.await;
|
|
if let Some(error) = session.has_failed() {
|
|
return Err(format!("Server rejected RCPT-TO: {}", error.trim()));
|
|
}
|
|
|
|
// DATA
|
|
session.data.message = message;
|
|
let response = session.queue_message().await;
|
|
if let smtp::core::State::Accepted(queue_id) = session.state {
|
|
Ok(queue_id)
|
|
} else {
|
|
Err(format!(
|
|
"Server rejected DATA: {}",
|
|
std::str::from_utf8(&response).unwrap().trim()
|
|
))
|
|
}
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(queue_id)) => {
|
|
trc::event!(
|
|
Calendar(trc::CalendarEvent::AlarmSent),
|
|
AccountId = account_id,
|
|
DocumentId = document_id,
|
|
QueueId = queue_id,
|
|
);
|
|
}
|
|
Ok(Err(err)) => {
|
|
trc::event!(
|
|
Calendar(trc::CalendarEvent::AlarmFailed),
|
|
AccountId = account_id,
|
|
DocumentId = document_id,
|
|
Reason = err,
|
|
);
|
|
}
|
|
Err(_) => {
|
|
trc::event!(
|
|
Server(trc::ServerEvent::ThreadError),
|
|
Details = "Join Error",
|
|
AccountId = account_id,
|
|
DocumentId = document_id,
|
|
CausedBy = trc::location!(),
|
|
);
|
|
return Ok(TaskResult::temporary("Thread join error"));
|
|
}
|
|
}
|
|
|
|
build_next_alarm(server, account_id, document_id, event)
|
|
}
|
|
|
|
async fn send_display_alarm(
|
|
server: &Server,
|
|
task: &TaskCalendarAlarmNotification,
|
|
) -> trc::Result<TaskResult> {
|
|
// Fetch event
|
|
let account_id = task.account_id.document_id();
|
|
let document_id = task.document_id.document_id();
|
|
let Some(event_) = server
|
|
.store()
|
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
|
account_id,
|
|
Collection::CalendarEvent,
|
|
document_id,
|
|
))
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
else {
|
|
trc::event!(
|
|
TaskManager(TaskManagerEvent::MetadataNotFound),
|
|
Details = "Calendar Event metadata not found",
|
|
AccountId = account_id,
|
|
DocumentId = document_id,
|
|
);
|
|
|
|
return Ok(TaskResult::Success(vec![]));
|
|
};
|
|
|
|
// Unarchive event
|
|
let event = event_
|
|
.unarchive::<CalendarEvent>()
|
|
.caused_by(trc::location!())?;
|
|
|
|
let recurrence_id = task.recurrence_id;
|
|
|
|
let ical = &event.data.event;
|
|
server
|
|
.broadcast_push_notification(PushNotification::CalendarAlert(CalendarAlert {
|
|
account_id,
|
|
event_id: document_id,
|
|
recurrence_id,
|
|
uid: ical.uids().next().unwrap_or_default().to_string(),
|
|
alert_id: ical
|
|
.components
|
|
.get(task.alarm_id as usize)
|
|
.and_then(|c| c.property(&ICalendarProperty::Jsid))
|
|
.and_then(|v| v.values.first())
|
|
.and_then(|v| v.as_text())
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| {
|
|
format!(
|
|
"k{}",
|
|
ical.components
|
|
.get(task.event_id as usize)
|
|
.and_then(|c| c
|
|
.component_ids
|
|
.iter()
|
|
.position(|id| id.to_native() == task.alarm_id as u32))
|
|
.unwrap_or_default()
|
|
+ 1
|
|
)
|
|
}),
|
|
}))
|
|
.await;
|
|
|
|
build_next_alarm(server, account_id, document_id, event)
|
|
}
|
|
|
|
fn build_next_alarm(
|
|
server: &Server,
|
|
account_id: u32,
|
|
document_id: u32,
|
|
event: &ArchivedCalendarEvent,
|
|
) -> trc::Result<TaskResult> {
|
|
// Find next alarm time and write to task queue
|
|
let now = now() as i64;
|
|
if let Some(next_alarm) =
|
|
event
|
|
.data
|
|
.next_alarm(now, Default::default())
|
|
.and_then(|next_alarm| {
|
|
// Verify minimum interval
|
|
let max_next_alarm = now + server.core.groupware.alarms_minimum_interval;
|
|
if next_alarm.alarm_time < max_next_alarm {
|
|
trc::event!(
|
|
Calendar(trc::CalendarEvent::AlarmSkipped),
|
|
Reason = "Next alarm skipped due to minimum interval",
|
|
Details = next_alarm.alarm_time - now,
|
|
AccountId = account_id,
|
|
DocumentId = document_id,
|
|
);
|
|
event.data.next_alarm(max_next_alarm, Default::default())
|
|
} else {
|
|
Some(next_alarm)
|
|
}
|
|
})
|
|
{
|
|
Ok(TaskResult::Update(
|
|
next_alarm.build_write_ops(account_id, document_id),
|
|
))
|
|
} else {
|
|
Ok(TaskResult::Success(vec![]))
|
|
}
|
|
}
|
|
|
|
struct Details {
|
|
to: String,
|
|
subject: String,
|
|
body: String,
|
|
}
|
|
|
|
async fn build_template(
|
|
server: &Server,
|
|
account_info: &AccountInfo,
|
|
alarm: &TaskCalendarAlarmEmail,
|
|
event: &ArchivedCalendarEvent,
|
|
logo_cid: &str,
|
|
) -> trc::Result<Option<Details>> {
|
|
let account_id = alarm.account_id.document_id();
|
|
let document_id = alarm.document_id.document_id();
|
|
let (Some(event_component), Some(alarm_component)) = (
|
|
event.data.event.components.get(alarm.event_id as usize),
|
|
event.data.event.components.get(alarm.alarm_id as usize),
|
|
) else {
|
|
trc::event!(
|
|
TaskManager(TaskManagerEvent::MetadataNotFound),
|
|
Details = "Calendar Alarm component not found",
|
|
AccountId = account_id,
|
|
DocumentId = document_id,
|
|
);
|
|
return Ok(None);
|
|
};
|
|
|
|
// Build webcal URI
|
|
let webcal_uri = match event.webcal_uri(server, account_info).await {
|
|
Ok(uri) => uri,
|
|
Err(err) => {
|
|
trc::error!(
|
|
err.account_id(account_id)
|
|
.document_id(document_id)
|
|
.caused_by(trc::location!())
|
|
.details("Failed to generate webcal URI")
|
|
);
|
|
String::from("#")
|
|
}
|
|
};
|
|
|
|
// Obtain alarm details
|
|
let mut summary = None;
|
|
let mut description = None;
|
|
let mut rcpt_to = None;
|
|
let mut location = None;
|
|
let mut conference = None;
|
|
let mut organizer = None;
|
|
let mut guests = vec![];
|
|
|
|
for entry in alarm_component.entries.iter() {
|
|
match &entry.name {
|
|
ArchivedICalendarProperty::Summary => {
|
|
summary = entry.values.first().and_then(|v| v.as_text());
|
|
}
|
|
ArchivedICalendarProperty::Description => {
|
|
description = entry.values.first().and_then(|v| v.as_text());
|
|
}
|
|
ArchivedICalendarProperty::Attendee => {
|
|
rcpt_to = entry
|
|
.values
|
|
.first()
|
|
.and_then(|v| v.as_text())
|
|
.map(strip_mailto_scheme)
|
|
.and_then(sanitize_email);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
for entry in event_component.entries.iter() {
|
|
match &entry.name {
|
|
ArchivedICalendarProperty::Summary if summary.is_none() => {
|
|
summary = entry.values.first().and_then(|v| v.as_text());
|
|
}
|
|
ArchivedICalendarProperty::Description if description.is_none() => {
|
|
description = entry.values.first().and_then(|v| v.as_text());
|
|
}
|
|
ArchivedICalendarProperty::Location => {
|
|
location = entry.values.first().and_then(|v| v.as_text());
|
|
}
|
|
ArchivedICalendarProperty::Conference if conference.is_none() => {
|
|
conference = entry.values.first().and_then(|v| v.as_text());
|
|
}
|
|
ArchivedICalendarProperty::Organizer | ArchivedICalendarProperty::Attendee => {
|
|
let email = entry
|
|
.values
|
|
.first()
|
|
.and_then(|v| v.as_text())
|
|
.map(strip_mailto_scheme);
|
|
let name = entry.params.iter().find_map(|param| {
|
|
if let ArchivedICalendarParameterName::Cn = param.name {
|
|
param.value.as_text()
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
if email.is_some() || name.is_some() {
|
|
if matches!(entry.name, ArchivedICalendarProperty::Organizer) {
|
|
organizer = Some((email, name));
|
|
} else {
|
|
guests.push((email, name));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Validate recipient
|
|
let rcpt_to = if let Some(rcpt_to) = rcpt_to {
|
|
if server.core.groupware.alarms_allow_external_recipients
|
|
|| account_info.addresses().contains(&rcpt_to)
|
|
{
|
|
rcpt_to
|
|
} else {
|
|
trc::event!(
|
|
Calendar(trc::CalendarEvent::AlarmRecipientOverride),
|
|
Reason = "External recipient not allowed for calendar alarms",
|
|
Details = rcpt_to,
|
|
AccountId = account_id,
|
|
DocumentId = document_id,
|
|
);
|
|
|
|
account_info.name().to_string()
|
|
}
|
|
} else {
|
|
account_info.name().to_string()
|
|
};
|
|
|
|
|
|
// inbuxa: BT-11, BT-18: the operator's template, read now, else the built-in
|
|
#[cfg(not(feature = "enterprise"))]
|
|
let custom = inbuxa_features::branding::templates::stored(
|
|
server.registry(),
|
|
inbuxa_features::branding::templates::Which::Alarm,
|
|
)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
#[cfg(not(feature = "enterprise"))]
|
|
let template = custom.as_ref().unwrap_or(&server.core.groupware.alarms_template);
|
|
let formatter = TextFormatter::new(account_info.locale().as_str())?;
|
|
let locale = formatter.locale;
|
|
|
|
let start = formatter.field_to_string(
|
|
&ItipValue::Time(ItipTime {
|
|
start: alarm.event_start.timestamp(),
|
|
tz_id: alarm.event_start_tz as u16,
|
|
}),
|
|
DateStyle::Short,
|
|
);
|
|
let end = formatter.field_to_string(
|
|
&ItipValue::Time(ItipTime {
|
|
start: alarm.event_end.timestamp(),
|
|
tz_id: alarm.event_end_tz as u16,
|
|
}),
|
|
DateStyle::Short,
|
|
);
|
|
let subject = format!(
|
|
"{}: {} @ {}",
|
|
locale.calendar_alarm_subject_prefix,
|
|
summary.or(description).unwrap_or("No Subject"),
|
|
start
|
|
);
|
|
let organizer = organizer
|
|
.map(|(email, name)| match (email, name) {
|
|
(Some(email), Some(name)) => format!("{} <{}>", name, email),
|
|
(Some(email), None) => email.to_string(),
|
|
(None, Some(name)) => name.to_string(),
|
|
_ => unreachable!(),
|
|
})
|
|
.unwrap_or_else(|| account_info.name().to_string());
|
|
let logo_cid = format!("cid:{logo_cid}");
|
|
let mut variables = Variables::new();
|
|
variables.insert_single(CalendarTemplateVariable::PageTitle, subject.as_str());
|
|
variables.insert_single(CalendarTemplateVariable::Lang, locale.name);
|
|
variables.insert_single(CalendarTemplateVariable::Dir, locale.direction);
|
|
variables.insert_single(
|
|
CalendarTemplateVariable::Header,
|
|
locale.calendar_alarm_header,
|
|
);
|
|
variables.insert_single(
|
|
CalendarTemplateVariable::Footer,
|
|
locale.calendar_alarm_footer,
|
|
);
|
|
variables.insert_single(
|
|
CalendarTemplateVariable::ActionName,
|
|
locale.calendar_alarm_open,
|
|
);
|
|
variables.insert_single(CalendarTemplateVariable::ActionUrl, webcal_uri.as_str());
|
|
variables.insert_single(
|
|
CalendarTemplateVariable::AttendeesTitle,
|
|
locale.calendar_attendees,
|
|
);
|
|
if let Some(summary) = summary.filter(|summary| !summary.is_empty()) {
|
|
variables.insert_single(CalendarTemplateVariable::EventTitle, summary);
|
|
}
|
|
variables.insert_single(CalendarTemplateVariable::LogoCid, logo_cid.as_str());
|
|
if let Some(description) = description {
|
|
variables.insert_single(CalendarTemplateVariable::EventDescription, description);
|
|
}
|
|
variables.insert_block(
|
|
CalendarTemplateVariable::EventDetails,
|
|
[
|
|
Some(vec![
|
|
(CalendarTemplateVariable::Key, locale.calendar_start),
|
|
(CalendarTemplateVariable::Value, start.as_str()),
|
|
]),
|
|
Some(vec![
|
|
(CalendarTemplateVariable::Key, locale.calendar_end),
|
|
(CalendarTemplateVariable::Value, end.as_str()),
|
|
]),
|
|
location.map(|location| {
|
|
vec![
|
|
(CalendarTemplateVariable::Key, locale.calendar_location),
|
|
(CalendarTemplateVariable::Value, location),
|
|
]
|
|
}),
|
|
conference.map(|conference| {
|
|
let mut detail = vec![
|
|
(CalendarTemplateVariable::Key, locale.calendar_conference),
|
|
(CalendarTemplateVariable::Value, conference),
|
|
];
|
|
if let Some(link) = hyperlink(conference) {
|
|
detail.push((CalendarTemplateVariable::Link, link));
|
|
}
|
|
detail
|
|
}),
|
|
Some(vec![
|
|
(CalendarTemplateVariable::Key, locale.calendar_organizer),
|
|
(CalendarTemplateVariable::Value, organizer.as_str()),
|
|
]),
|
|
]
|
|
.into_iter()
|
|
.flatten(),
|
|
);
|
|
if !guests.is_empty() {
|
|
variables.insert_block(
|
|
CalendarTemplateVariable::Attendees,
|
|
guests.into_iter().map(|(email, name)| {
|
|
[
|
|
(CalendarTemplateVariable::Key, name.unwrap_or_default()),
|
|
(CalendarTemplateVariable::Value, email.unwrap_or_default()),
|
|
]
|
|
}),
|
|
);
|
|
}
|
|
Ok(Some(Details {
|
|
to: rcpt_to,
|
|
body: template.eval(&variables),
|
|
subject,
|
|
}))
|
|
}
|