diff --git a/melib/src/conf.rs b/melib/src/conf.rs index 7cf0a5e5..18c27ed7 100644 --- a/melib/src/conf.rs +++ b/melib/src/conf.rs @@ -27,6 +27,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::{ backends::SpecialUsageMailbox, + email::Address, error::{Error, Result}, }; pub use crate::{SortField, SortOrder}; @@ -55,11 +56,7 @@ impl AccountSettings { /// Create the account's display name from fields /// [`AccountSettings::identity`] and [`AccountSettings::display_name`]. pub fn make_display_name(&self) -> String { - if let Some(d) = self.display_name.as_ref() { - format!("{} <{}>", d, self.identity) - } else { - self.identity.to_string() - } + Address::new(self.display_name.clone(), self.identity.clone()).to_string() } pub fn order(&self) -> Option<(SortField, SortOrder)> { diff --git a/melib/src/jmap/connection.rs b/melib/src/jmap/connection.rs index 57e6a501..c2b2c62a 100644 --- a/melib/src/jmap/connection.rs +++ b/melib/src/jmap/connection.rs @@ -19,7 +19,7 @@ * along with meli. If not, see . */ -use std::sync::MutexGuard; +use std::{convert::TryFrom, sync::MutexGuard}; use isahc::config::Configurable; @@ -204,6 +204,89 @@ impl JmapConnection { *self.store.online_status.lock().await = (Instant::now(), Ok(())); *self.session.lock().unwrap() = session; + /* Fetch account identities. */ + + let mut id_list = { + let mut req = Request::new(self.request_no.clone()); + let identity_get = IdentityGet::new().account_id(self.mail_account_id()); + req.add_call(&identity_get); + let mut res_text = self.post_async(None, serde_json::to_string(&req)?).await?; + let res_text = res_text.text().await?; + let mut v: MethodResponse = match deserialize_from_str(&res_text) { + Err(err) => { + *self.store.online_status.lock().await = (Instant::now(), Err(err.clone())); + return Err(err); + } + Ok(s) => s, + }; + let GetResponse:: { list, .. } = + GetResponse::::try_from(v.method_responses.remove(0))?; + list + }; + if id_list.is_empty() { + let mut req = Request::new(self.request_no.clone()); + let identity_set = IdentitySet( + Set::::new() + .account_id(self.mail_account_id()) + .create(Some({ + let address = + crate::email::Address::try_from(self.store.main_identity.as_str()) + .unwrap_or_else(|_| { + crate::email::Address::new( + None, + self.store.main_identity.clone(), + ) + }); + let id: Id = Id::new_uuid_v4(); + log::trace!( + "identity id = {}, {:#?}", + id, + IdentityObject { + id: id.clone(), + name: address.get_display_name().unwrap_or_default(), + email: address.get_email(), + ..IdentityObject::default() + } + ); + indexmap! { + id.clone().into() => IdentityObject { + id, + name: address.get_display_name().unwrap_or_default(), + email: address.get_email(), + ..IdentityObject::default() + } + } + })), + ); + req.add_call(&identity_set); + let mut res_text = self.post_async(None, serde_json::to_string(&req)?).await?; + let res_text = res_text.text().await?; + let _: MethodResponse = match deserialize_from_str(&res_text) { + Err(err) => { + *self.store.online_status.lock().await = (Instant::now(), Err(err.clone())); + return Err(err); + } + Ok(s) => s, + }; + let mut req = Request::new(self.request_no.clone()); + let identity_get = IdentityGet::new().account_id(self.mail_account_id()); + req.add_call(&identity_get); + let mut res_text = self.post_async(None, serde_json::to_string(&req)?).await?; + let res_text = res_text.text().await?; + let mut v: MethodResponse = match deserialize_from_str(&res_text) { + Err(err) => { + *self.store.online_status.lock().await = (Instant::now(), Err(err.clone())); + return Err(err); + } + Ok(s) => s, + }; + let GetResponse:: { list, .. } = + GetResponse::::try_from(v.method_responses.remove(0))?; + id_list = list; + } + self.session.lock().unwrap().identities = + id_list.into_iter().map(|id| (id.id.clone(), id)).collect(); + Ok(()) } @@ -211,6 +294,16 @@ impl JmapConnection { self.session.lock().unwrap().primary_accounts[JMAP_MAIL_CAPABILITY].clone() } + pub fn mail_identity_id(&self) -> Option> { + self.session + .lock() + .unwrap() + .identities + .keys() + .next() + .cloned() + } + pub fn session_guard(&'_ self) -> MutexGuard<'_, Session> { self.session.lock().unwrap() } diff --git a/melib/src/jmap/mod.rs b/melib/src/jmap/mod.rs index bcb06bfe..fe43af1a 100644 --- a/melib/src/jmap/mod.rs +++ b/melib/src/jmap/mod.rs @@ -31,7 +31,7 @@ use std::{ use futures::{lock::Mutex as FutureMutex, Stream}; use indexmap::IndexMap; use isahc::{config::RedirectPolicy, AsyncReadResponseExt, HttpClient}; -use serde_json::Value; +use serde_json::{json, Value}; use smallvec::SmallVec; use crate::{ @@ -189,6 +189,8 @@ impl JmapServerConf { pub struct Store { pub account_name: Arc, pub account_hash: AccountHash, + pub main_identity: String, + pub extra_identities: Vec, pub account_id: Arc>>, pub byte_cache: Arc>>, pub id_store: Arc>>>, @@ -926,6 +928,170 @@ impl MailBackend for JmapType { "Deleting messages is currently unimplemented for the JMAP backend.", )) } + + // [ref:TODO] add support for BLOB extension + fn submit( + &self, + bytes: Vec, + mailbox_hash: Option, + _flags: Option, + ) -> ResultFuture<()> { + let store = self.store.clone(); + let connection = self.connection.clone(); + Ok(Box::pin(async move { + // Steps: + // + // 1. upload blob/save to Draft as EmailObject + // 2. get id and make an EmailSubmissionObject + // 3. This call then sends the Email immediately, and if successful, removes the + // "$draft" flag and moves it from the Drafts folder to the Sent folder. + let (draft_mailbox_id, sent_mailbox_id) = { + let mailboxes_lck = store.mailboxes.read().unwrap(); + let find_fn = |usage: SpecialUsageMailbox| -> Result> { + if let Some(sent_folder) = + mailboxes_lck.values().find(|m| m.special_usage() == usage) + { + Ok(sent_folder.id.clone()) + } else if let Some(sent_folder) = mailboxes_lck + .values() + .find(|m| m.special_usage() == SpecialUsageMailbox::Inbox) + { + Ok(sent_folder.id.clone()) + } else { + Ok(mailboxes_lck + .values() + .next() + .ok_or_else(|| { + Error::new(format!( + "Account `{}` has no mailboxes.", + store.account_name + )) + })? + .id + .clone()) + } + }; + + (find_fn(SpecialUsageMailbox::Drafts)?, { + if let Some(h) = mailbox_hash { + if let Some(m) = mailboxes_lck.get(&h) { + m.id.clone() + } else { + return Err(Error::new(format!( + "Could not find mailbox with hash {h}", + ))); + } + } else { + find_fn(SpecialUsageMailbox::Sent)? + } + }) + }; + let conn = connection.lock().await; + // [ref:TODO] smarter identity detection based on From: ? + let Some(identity_id) = conn.mail_identity_id() else { + return Err(Error::new( + "You need to setup an Identity in the JMAP server.", + )); + }; + let upload_url = { conn.session.lock().unwrap().upload_url.clone() }; + let mut res = conn + .post_async( + Some(&upload_request_format( + upload_url.as_str(), + &conn.mail_account_id(), + )), + bytes, + ) + .await?; + let res_text = res.text().await?; + + let upload_response: UploadResponse = match deserialize_from_str(&res_text) { + Err(err) => { + *conn.store.online_status.lock().await = (Instant::now(), Err(err.clone())); + return Err(err); + } + Ok(s) => s, + }; + { + let mut req = Request::new(conn.request_no.clone()); + let creation_id: Id = "newid".into(); + let import_call: EmailImport = EmailImport::new() + .account_id(conn.mail_account_id()) + .emails(indexmap! { + creation_id => EmailImportObject::new() + .blob_id(upload_response.blob_id) + .keywords(indexmap! { + "$draft".to_string() => true, + "$seen".to_string() => true, + }) + .mailbox_ids(indexmap! { + draft_mailbox_id.clone() => true, + }), + }); + + req.add_call(&import_call); + + let mut res = conn.post_async(None, serde_json::to_string(&req)?).await?; + let res_text = res.text().await?; + let v: MethodResponse = match deserialize_from_str(&res_text) { + Err(err) => { + *conn.store.online_status.lock().await = (Instant::now(), Err(err.clone())); + return Err(err); + } + Ok(s) => s, + }; + + // [ref:TODO] handle this better? + let res: Value = serde_json::from_str(v.method_responses[0].get())?; + + let _new_state = res[1]["newState"].as_str().unwrap().to_string(); + let _old_state = res[1]["oldState"].as_str().unwrap().to_string(); + let email_id = Id::from( + res[1]["created"]["newid"]["id"] + .as_str() + .unwrap() + .to_string(), + ); + + let mut req = Request::new(conn.request_no.clone()); + let subm_set_call: EmailSubmissionSet = EmailSubmissionSet::new( + Set::::new() + .account_id(conn.mail_account_id()) + .create(Some(indexmap! { + Argument::from(Id::from("k1490")) => EmailSubmissionObject::new( + /* account_id: */ conn.mail_account_id(), + /* identity_id: */ identity_id, + /* email_id: */ email_id, + /* envelope: */ None, + /* undo_status: */ None + ) + })), + ) + .on_success_update_email(Some(indexmap! { + "#k1490".into() => json!({ + format!("mailboxIds/{draft_mailbox_id}"): null, + format!("mailboxIds/{sent_mailbox_id}"): true, + "keywords/$draft": null + }) + })); + + req.add_call(&subm_set_call); + let mut res = conn.post_async(None, serde_json::to_string(&req)?).await?; + + let res_text = res.text().await?; + + // [ref:TODO] parse/return any error. + let _: MethodResponse = match deserialize_from_str(&res_text) { + Err(err) => { + *conn.store.online_status.lock().await = (Instant::now(), Err(err.clone())); + return Err(err); + } + Ok(s) => s, + }; + } + Ok(()) + })) + } } impl JmapType { @@ -945,6 +1111,8 @@ impl JmapType { let store = Arc::new(Store { account_name: Arc::new(s.name.clone()), account_hash, + main_identity: s.make_display_name(), + extra_identities: s.extra_identities.clone(), account_id: Arc::new(Mutex::new(Id::empty())), online_status, event_consumer, diff --git a/melib/src/jmap/objects.rs b/melib/src/jmap/objects.rs index 8f61de5f..3e9635f8 100644 --- a/melib/src/jmap/objects.rs +++ b/melib/src/jmap/objects.rs @@ -32,3 +32,6 @@ pub use thread::*; mod identity; pub use identity::*; + +mod submission; +pub use submission::*; diff --git a/melib/src/jmap/objects/identity.rs b/melib/src/jmap/objects/identity.rs index e4709565..b8cf5478 100644 --- a/melib/src/jmap/objects/identity.rs +++ b/melib/src/jmap/objects/identity.rs @@ -25,7 +25,7 @@ use super::*; /// /// An *Identity* object stores information about an email address or domain the /// user may send from. -#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone, Default)] #[serde(rename_all = "camelCase")] pub struct IdentityObject { /// id: `Id` (immutable; server-set) @@ -87,13 +87,15 @@ impl Object for IdentityObject { const NAME: &'static str = "Identity"; } -#[derive(Serialize, Clone, Copy, Debug)] -#[serde(rename_all = "camelCase")] -pub struct IdentityGet; +pub type IdentityGet = Get; impl Method for IdentityGet { const NAME: &'static str = "Identity/get"; } +pub type IdentityChanges = Changes; +impl Method for IdentityChanges { + const NAME: &'static str = "Identity/changes"; +} // [ref:TODO]: implement `forbiddenFrom` error for Identity/set. /// `IdentitySet` method. @@ -105,18 +107,88 @@ impl Method for IdentityGet { /// o "forbiddenFrom": The user is not allowed to send from the address /// given as the "email" property of the Identity. /// ``` -#[derive(Serialize, Clone, Copy, Debug)] -#[serde(rename_all = "camelCase")] -pub struct IdentitySet; +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase", transparent)] +pub struct IdentitySet(pub Set); impl Method for IdentitySet { const NAME: &'static str = "Identity/set"; } -#[derive(Serialize, Clone, Copy, Debug)] -#[serde(rename_all = "camelCase")] -pub struct IdentityChanges; - -impl Method for IdentityChanges { - const NAME: &'static str = "Identity/changes"; +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use serde_json::json; + + use crate::jmap::*; + + #[test] + fn test_jmap_identity_methods() { + let account_id = "blahblah"; + let prev_seq = 33; + let main_identity = "user@example.com"; + let mut req = Request::new(Arc::new(Mutex::new(prev_seq))); + + let identity_set = IdentitySet( + Set::::new() + .account_id(account_id.into()) + .create(Some({ + let id: Id = main_identity.into(); + let address = crate::email::Address::try_from(main_identity) + .unwrap_or_else(|_| crate::email::Address::new(None, main_identity.into())); + indexmap! { + id.clone().into() => IdentityObject { + id, + name: address.get_display_name().unwrap_or_default(), + email: address.get_email(), + ..IdentityObject::default() + } + } + })), + ); + req.add_call(&identity_set); + + let identity_get = IdentityGet::new().account_id(account_id.into()); + req.add_call(&identity_get); + + assert_eq!( + json! {&req}, + json! {{ + "methodCalls" : [ + [ + "Identity/set", + { + "accountId" : account_id, + "create" : { + "user@example.com" : { + "bcc" : null, + "email" : main_identity, + "htmlSignature" : "", + "name" : "", + "replyTo" : null, + "textSignature" : "" + } + }, + "destroy" : null, + "ifInState" : null, + "update" : null + }, + "m33" + ], + [ + "Identity/get", + { + "accountId": account_id + }, + "m34" + ] + ], + "using" : [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail" + ] + }}, + ); + } } diff --git a/melib/src/jmap/objects/submission.rs b/melib/src/jmap/objects/submission.rs new file mode 100644 index 00000000..2ba39606 --- /dev/null +++ b/melib/src/jmap/objects/submission.rs @@ -0,0 +1,499 @@ +/* + * meli + * + * Copyright 2023 Manos Pitsidianakis + * + * This file is part of meli. + * + * meli is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * meli is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with meli. If not, see . + */ + +use indexmap::IndexMap; +use serde::ser::{Serialize, SerializeStruct, Serializer}; + +use super::*; + +/// `UndoStatus` +/// +/// This represents whether the submission may be canceled. This is +/// server set on create and MUST be one of the following values: +/// * `pending`: It may be possible to cancel this submission. +/// * `final`: The message has been relayed to at least one recipient +/// in a manner that cannot be recalled. It is no longer possible +/// to cancel this submission. +/// * `canceled`: The submission was canceled and will not be +/// delivered to any recipient. +/// On systems that do not support unsending, the value of this +/// property will always be `final`. On systems that do support +/// canceling submission, it will start as `pending` and MAY +/// transition to `final` when the server knows it definitely cannot +/// recall the message, but it MAY just remain `pending`. If in +/// pending state, a client can attempt to cancel the submission by +/// setting this property to `canceled`; if the update succeeds, the +/// submission was successfully canceled, and the message has not been +/// delivered to any of the original recipients. +#[derive(Deserialize, Serialize, Default, Clone, Copy, Debug)] +#[serde(rename_all = "camelCase")] +pub enum UndoStatus { + /// It may be possible to cancel this submission. + Pending, + /// The message has been relayed to at least one recipient in a manner that + /// cannot be recalled. It is no longer possible to cancel this + /// submission. + #[default] + Final, + /// The submission was canceled and will not be delivered to any recipient. + Canceled, +} + +/// This represents the delivery status for each of the submission's +/// recipients, if known. This property MAY not be supported by all +/// servers, in which case it will remain null. Servers that support +/// it SHOULD update the EmailSubmission object each time the status +/// of any of the recipients changes, even if some recipients are +/// still being retried. +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DeliveryStatusObject { + /// The SMTP reply string returned for this recipient when the + /// server last tried to relay the message, or in a later Delivery + /// Status Notification (DSN, as defined in `[RFC3464]`) response for + /// the message. This SHOULD be the response to the RCPT TO stage, + /// unless this was accepted and the message as a whole was + /// rejected at the end of the DATA stage, in which case the DATA + /// stage reply SHOULD be used instead. + /// + /// Multi-line SMTP responses should be concatenated to a single + /// string as follows: + /// + /// + The hyphen following the SMTP code on all but the last line + /// is replaced with a space. + /// + /// + Any prefix in common with the first line is stripped from + /// lines after the first. + /// + /// + CRLF is replaced by a space. + /// + /// For example: + /// + /// 550-5.7.1 Our system has detected that this message is + /// 550 5.7.1 likely spam. + /// + /// would become: + /// + /// 550 5.7.1 Our system has detected that this message is likely spam. + /// + /// For messages relayed via an alternative to SMTP, the server MAY + /// generate a synthetic string representing the status instead. + /// If it does this, the string MUST be of the following form: + /// + /// + A 3-digit SMTP reply code, as defined in `[RFC5321]`, + /// Section 4.2.3. + /// + /// + Then a single space character. + /// + /// + Then an SMTP Enhanced Mail System Status Code as defined in + /// `[RFC3463]`, with a registry defined in `[RFC5248]`. + /// + /// + Then a single space character. + /// + /// + Then an implementation-specific information string with a + /// human-readable explanation of the response. + pub smtp_reply: String, + + /// Represents whether the message has been successfully delivered + /// to the recipient. + pub delivered: String, + + /// Represents whether the message has been displayed to the recipient. + pub displayed: Displayed, +} + +/// Represents whether the message has been displayed to the recipient. +/// If a Message Disposition Notification (MDN) is received for +/// this recipient with Disposition-Type (as per `[RFC8098]`, +/// Section 3.2.6.2) equal to `displayed`, this property SHOULD be +/// set to `yes`. +#[derive(Deserialize, Serialize, Default, Clone, Copy, Debug)] +#[serde(rename_all = "camelCase")] +pub enum Displayed { + /// The recipient's system claims the message content has been displayed to + /// the recipient. Note that there is no guarantee that the recipient + /// has noticed, read, or understood the content. + Yes, + /// The display status is unknown. This is the initial value. + #[default] + Unknown, +} + +/// Represents whether the message has been successfully delivered +/// to the recipient. +/// +/// Note that successful relaying to an external SMTP server SHOULD +/// NOT be taken as an indication that the message has successfully +/// reached the final mail store. In this case though, the server +/// may receive a DSN response, if requested. +/// +/// If a DSN is received for the recipient with Action equal to +/// `delivered`, as per `[RFC3464]`, Section 2.3.3, then the +/// `delivered` property SHOULD be set to `yes`; if the Action +/// equals `failed`, the property SHOULD be set to `no`. Receipt +/// of any other DSN SHOULD NOT affect this property. +/// +/// The server MAY also set this property based on other feedback +/// channels. +#[derive(Deserialize, Serialize, Default, Clone, Copy, Debug)] +#[serde(rename_all = "camelCase")] +pub enum Delivered { + /// The message is in a local mail queue and the status will change once it + /// exits the local mail queues. The `smtpReply` property may still + /// change. + #[default] + Queued, + /// The message was successfully delivered to the mail store of the + /// recipient. The `smtpReply` property is final. + Yes, + /// Delivery to the recipient permanently failed. The `smtpReply` property + /// is final. + No, + /// The final delivery status is unknown, (e.g., it was relayed to an + /// external machine and no further information is available). The + /// `smtpReply` property may still change if a DSN arrives. + Unknown, +} + +/// # Email Submission +/// +/// An *EmailSubmission* object represents the submission of an Email for +/// delivery to one or more recipients. It has the following properties: +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct EmailSubmissionObject { + /// accountId: `Id` + /// The id of the account to use. + #[serde(skip_serializing)] + pub account_id: Id, + /// identityId: `Id` (immutable) + /// The id of the Identity to associate with this submission. + pub identity_id: Id, + /// The id of the Email to send. The Email being sent does not have + /// to be a draft, for example, when "redirecting" an existing Email + /// to a different address. + pub email_id: Argument>, + /// The Thread id of the Email to send. This is set by the server to + /// the `threadId` property of the Email referenced by the `emailId`. + #[serde(skip_serializing)] + pub thread_id: Id, + /// Information for use when sending via SMTP. + pub envelope: Option, + /// sendAt: `UTCDate` (immutable; server-set) + /// The date the submission was/will be released for delivery. If the + /// client successfully used `FUTURERELEASE` `[RFC4865]` with the + /// submission, this MUST be the time when the server will release the + /// message; otherwise, it MUST be the time the `EmailSubmission` was + /// created. + #[serde(skip_serializing)] + pub send_at: String, + /// This represents whether the submission may be canceled. + pub undo_status: UndoStatus, + /// deliveryStatus: `String[DeliveryStatus]|null` (server-set) + /// + /// This represents the delivery status for each of the submission's + /// recipients, if known. This property MAY not be supported by all + /// servers, in which case it will remain null. Servers that support + /// it SHOULD update the `EmailSubmission` object each time the status + /// of any of the recipients changes, even if some recipients are + /// still being retried. + #[serde(skip_serializing)] + pub delivery_status: Option>, + /// dsnBlobIds: `Id[]` (server-set) + /// A list of blob ids for DSNs `[RFC3464]` received for this + /// submission, in order of receipt, oldest first. The blob is the + /// whole MIME message (with a top-level content-type of "multipart/ + /// report"), as received. + #[serde(skip_serializing)] + pub dsn_blob_ids: Vec>, + /// mdnBlobIds: `Id[]` (server-set) + /// A list of blob ids for MDNs `[RFC8098]` received for this + /// submission, in order of receipt, oldest first. The blob is the + /// whole MIME message (with a top-level content-type of "multipart/ + /// report"), as received. + #[serde(skip_serializing)] + pub mdn_blob_ids: Vec>, +} + +impl Object for EmailSubmissionObject { + const NAME: &'static str = "EmailSubmission"; +} + +impl EmailSubmissionObject { + /// Create a new `EmailSubmissionObject`, with all the server-set fields + /// initialized as empty. + pub fn new( + account_id: Id, + identity_id: Id, + email_id: impl Into>>, + envelope: Option, + undo_status: Option, + ) -> Self { + Self { + account_id, + identity_id, + email_id: email_id.into(), + thread_id: "".into(), + envelope, + send_at: String::new(), + undo_status: undo_status.unwrap_or_default(), + delivery_status: None, + dsn_blob_ids: vec![], + mdn_blob_ids: vec![], + } + } +} + +impl Serialize for EmailSubmissionObject { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + let mut state = serializer.serialize_struct(stringify! {EmailSubmissionObject}, 4)?; + state.serialize_field("identityId", &self.identity_id)?; + state.serialize_field( + if matches!(self.email_id, Argument::Value(_)) { + "emailId" + } else { + "#emailId" + }, + &self.email_id, + )?; + state.serialize_field("envelope", &self.envelope)?; + state.serialize_field("undoStatus", &self.undo_status)?; + + state.end() + } +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct EmailSubmissionSet { + #[serde(flatten)] + pub set_call: Set, + /// onSuccessUpdateEmail: `Id[PatchObject]|null` + /// A map of [`EmailSubmission`] id to an object containing properties to + /// update on the [`Email`](EmailObject) object referenced by the + /// [`EmailSubmission`] if the create/update/destroy succeeds. (For + /// references to EmailSubmissions created in the same + /// `/set` invocation, this is equivalent to a creation-reference, so the id + /// will be the creation id prefixed with a `#`.) + #[serde(default)] + pub on_success_update_email: Option, PatchObject>>, + /// onSuccessDestroyEmail: `Id[]|null` + /// A list of EmailSubmission ids for which the Email with the + /// corresponding `emailId` should be destroyed if the create/update/ + /// destroy succeeds. (For references to EmailSubmission creations, + /// this is equivalent to a creation-reference, so the id will be the + /// creation id prefixed with a `#`.) + #[serde(default)] + pub on_success_destroy_email: Option>>, +} + +impl Method for EmailSubmissionSet { + const NAME: &'static str = "EmailSubmission/set"; +} + +impl EmailSubmissionSet { + pub fn new(set_call: Set) -> Self { + Self { + set_call, + on_success_update_email: None, + on_success_destroy_email: None, + } + } + + pub fn on_success_update_email( + self, + on_success_update_email: Option, PatchObject>>, + ) -> Self { + Self { + on_success_update_email, + ..self + } + } + + pub fn on_success_destroy_email( + self, + on_success_destroy_email: Option>>, + ) -> Self { + Self { + on_success_destroy_email, + ..self + } + } +} + +/// Information for use when sending via SMTP. +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct EnvelopeObject { + /// The email address to use as the return address in the SMTP + /// submission, plus any parameters to pass with the MAIL FROM + /// address. The JMAP server MAY allow the address to be the empty + /// string. + + /// When a JMAP server performs an SMTP message submission, it MAY + /// use the same id string for the ENVID parameter `[RFC3461]` and + /// the EmailSubmission object id. Servers that do this MAY + /// replace a client-provided value for ENVID with a server- + /// provided value. + pub mail_from: Address, + + /// The email addresses to send the message to, and any RCPT TO + /// parameters to pass with the recipient. + pub rcpt_to: Vec
, +} + +impl Object for EnvelopeObject { + const NAME: &'static str = "Envelope"; +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Address { + /// The email address being represented by the object. This is a + /// "Mailbox" as used in the Reverse-path or Forward-path of the + /// MAIL FROM or RCPT TO command in `[RFC5321]`. + pub email: String, + + /// Any parameters to send with the email address (either mail- + /// parameter or rcpt-parameter as appropriate, as specified in + /// `[RFC5321]`). If supplied, each key in the object is a parameter + /// name, and the value is either the parameter value (type + /// `String`) or null if the parameter does not take a value. For + /// both name and value, any xtext or unitext encodings are removed + /// (see `[RFC3461]` and `[RFC6533]`) and JSON string encoding is + /// applied. + pub parameters: Option, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn test_jmap_undo_status() { + let account_id: Id = "blahblah".into(); + let ident_id: Id = "sdusssssss".into(); + let email_id: Id = Id::from("683f9246-56d4-4d7d-bd0c-3d4de6db7cbf"); + let mut obj = EmailSubmissionObject::new( + account_id, + ident_id.clone(), + email_id.clone(), + None, + /* undo_status */ None, + ); + + assert_eq!( + json!(&obj), + json!({ + "emailId": email_id, + "envelope": null, + "identityId": &ident_id, + "undoStatus": "final", + }) + ); + + obj.undo_status = UndoStatus::Pending; + assert_eq!( + json!(&obj), + json!({ + "emailId": email_id, + "envelope": null, + "identityId": &ident_id, + "undoStatus": "pending", + }) + ); + obj.undo_status = UndoStatus::Final; + assert_eq!( + json!(&obj), + json!({ + "emailId": email_id, + "envelope": null, + "identityId": &ident_id, + "undoStatus": "final", + }) + ); + obj.undo_status = UndoStatus::Canceled; + assert_eq!( + json!(&obj), + json!({ + "emailId": email_id, + "envelope": null, + "identityId": &ident_id, + "undoStatus": "canceled", + }) + ); + } + + #[test] + fn test_jmap_email_submission_object() { + let account_id: Id = "blahblah".into(); + let ident_id: Id = "sdusssssss".into(); + let email_id: Id = Id::from("683f9246-56d4-4d7d-bd0c-3d4de6db7cbf"); + let obj = EmailSubmissionObject::new( + account_id.clone(), + ident_id.clone(), + email_id.clone(), + None, + /* undo_status */ None, + ); + + assert_eq!( + json!(&obj), + json!({ + "emailId": email_id, + "envelope": null, + "identityId": &ident_id, + "undoStatus": "final", + }) + ); + + let obj = EmailSubmissionObject::new( + account_id, + ident_id.clone(), + /* email_id: */ + Argument::reference::( + 42, + ResultField::::new("/id"), + ), + None, + Some(UndoStatus::Final), + ); + + assert_eq!( + json!(&obj), + json!({ + "#emailId": { + "name": "Email/import", + "path": "/id", + "resultOf": "m42", + }, + "envelope": null, + "identityId": &ident_id, + "undoStatus": "final", + }) + ); + } +} diff --git a/melib/src/jmap/rfc8620.rs b/melib/src/jmap/rfc8620.rs index bd700d05..a43d457b 100644 --- a/melib/src/jmap/rfc8620.rs +++ b/melib/src/jmap/rfc8620.rs @@ -682,7 +682,7 @@ impl ChangesResponse { /// and dependencies that may exist if doing multiple operations at once /// (for example, to ensure there is always a minimum number of a certain /// record type). -#[derive(Deserialize, Serialize, Clone, Debug)] +#[derive(Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct Set where @@ -802,6 +802,38 @@ where } } +impl Serialize for Set { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let fields_no = 5; + + let mut state = serializer.serialize_struct("Set", fields_no)?; + state.serialize_field("accountId", &self.account_id)?; + state.serialize_field("ifInState", &self.if_in_state)?; + state.serialize_field("update", &self.update)?; + state.serialize_field("destroy", &self.destroy)?; + if let Some(ref m) = self.create { + let map = m + .into_iter() + .map(|(k, v)| { + let mut v = serde_json::json!(v); + if let Some(ref mut obj) = v.as_object_mut() { + obj.remove("id"); + } + (k, v) + }) + .collect::>(); + state.serialize_field("create", &map)?; + } else { + state.serialize_field("create", &self.create)?; + } + + state.end() + } +} + #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct SetResponse { diff --git a/melib/src/jmap/rfc8620/argument.rs b/melib/src/jmap/rfc8620/argument.rs index b87cce35..159822ea 100644 --- a/melib/src/jmap/rfc8620/argument.rs +++ b/melib/src/jmap/rfc8620/argument.rs @@ -62,3 +62,154 @@ impl From for Argument { Self::Value(v) } } + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use serde_json::json; + + use crate::jmap::*; + + #[test] + fn test_jmap_argument_serde() { + let account_id = "blahblah"; + let blob_id: Id = Id::new_uuid_v4(); + let draft_mailbox_id: Id = Id::new_uuid_v4(); + let sent_mailbox_id: Id = Id::new_uuid_v4(); + let prev_seq = 33; + + let mut req = Request::new(Arc::new(Mutex::new(prev_seq))); + let creation_id: Id = "1".into(); + let import_call: EmailImport = + EmailImport::new() + .account_id(account_id.into()) + .emails(indexmap! { + creation_id => + EmailImportObject::new() + .blob_id(blob_id.clone()) + .keywords(indexmap! { + "$draft".to_string() => true, + }) + .mailbox_ids(indexmap! { + draft_mailbox_id.clone() => true, + }), + }); + + let prev_seq = req.add_call(&import_call); + + let subm_set_call: EmailSubmissionSet = EmailSubmissionSet::new( + Set::::new() + .account_id(account_id.into()) + .create(Some(indexmap! { + Argument::from(Id::from("k1490")) => EmailSubmissionObject::new( + /* account_id: */ account_id.into(), + /* identity_id: */ account_id.into(), + /* email_id: */ Argument::reference::(prev_seq, ResultField::::new("/id")), + /* envelope: */ None, + /* undo_status: */ None + ) + })), + ) + .on_success_update_email(Some( + indexmap! { + "#k1490".into() => json!({ + format!("mailboxIds/{draft_mailbox_id}"): null, + format!("mailboxIds/{sent_mailbox_id}"): true, + "keywords/$draft": null + }) + } + )); + _ = req.add_call(&subm_set_call); + + assert_eq!( + json! {&subm_set_call}, + json! {{ + "accountId": account_id, + "create": { + "k1490": { + "#emailId": { + "name": "Email/import", + "path":"/id", + "resultOf":"m33" + }, + "envelope": null, + "identityId": account_id, + "undoStatus": "final" + } + }, + "destroy": null, + "ifInState": null, + "onSuccessDestroyEmail": null, + "onSuccessUpdateEmail": { + "#k1490": { + "keywords/$draft": null, + format!("mailboxIds/{draft_mailbox_id}"): null, + format!("mailboxIds/{sent_mailbox_id}"): true + } + }, + "update": null, + }}, + ); + assert_eq!( + json! {&req}, + json! {{ + "methodCalls": [ + [ + "Email/import", + { + "accountId": account_id, + "emails": { + "1": { + "blobId": blob_id.to_string(), + "keywords": { + "$draft": true + }, + "mailboxIds": { + draft_mailbox_id.to_string(): true + }, + "receivedAt": null + } + } + }, + "m33" + ], + [ + "EmailSubmission/set", + { + "accountId": account_id, + "create": { + "k1490": { + "#emailId": { + "name": "Email/import", + "path": "/id", + "resultOf": "m33" + }, + "envelope": null, + "identityId": account_id, + "undoStatus": "final" + } + }, + "destroy": null, + "ifInState": null, + "onSuccessDestroyEmail": null, + "onSuccessUpdateEmail": { + "#k1490": { + "keywords/$draft": null, + format!("mailboxIds/{draft_mailbox_id}"): null, + format!("mailboxIds/{sent_mailbox_id}"): true + } + }, + "update": null + }, + "m34" + ] + ], + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail" + ] + }}, + ); + } +} diff --git a/melib/src/jmap/session.rs b/melib/src/jmap/session.rs index 38556c01..05031566 100644 --- a/melib/src/jmap/session.rs +++ b/melib/src/jmap/session.rs @@ -24,7 +24,10 @@ use std::sync::Arc; use indexmap::IndexMap; use serde_json::Value; -use crate::jmap::rfc8620::{Account, Id, Object, State}; +use crate::jmap::{ + rfc8620::{Account, Id, Object, State}, + IdentityObject, +}; #[derive(Deserialize, Serialize, Debug, Clone, Default)] #[serde(rename_all = "camelCase")] @@ -32,6 +35,8 @@ pub struct Session { pub capabilities: IndexMap, pub accounts: IndexMap, Account>, pub primary_accounts: IndexMap>, + #[serde(skip)] + pub identities: IndexMap, IdentityObject>, pub username: String, pub api_url: Arc, pub download_url: Arc,