mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
Split the bridge into a tutabridge-core crate, a Tauri v2 desktop app (src-tauri) and a React/TS UI (ui), keeping the CLI entrypoint at the workspace root. Add encrypted local storage (SQLCipher metadata index + encrypted .eml files) so mail persists across launches and only the delta is fetched. Wire the bridge to the Tuta Rust SDK via the tuta-repo submodule (batch loading, MailDetailsBlob reading, interactive 2FA login). Implement SMTP sending: build the draft and send it through Tuta's DraftService/SendDraftService, mirroring the web client (body in compressedBodyText, non-empty sender/recipient names, populated SendDraftParameters). Add unit tests for the draft/send payload building.
643 lines
22 KiB
Rust
643 lines
22 KiB
Rust
use base64::Engine;
|
|
use std::sync::Arc;
|
|
use crypto_primitives::aes::{Aes256Key, Iv, AES_256_KEY_SIZE};
|
|
use crypto_primitives::blake3::blake3_kdf;
|
|
use crypto_primitives::key::GenericAesKey;
|
|
use crypto_primitives::randomizer_facade::RandomizerFacade;
|
|
use tutasdk::bindings::file_client::{FileClient, FileClientError};
|
|
use tutasdk::bindings::rest_client::RestClient;
|
|
use tutasdk::crypto_entity_client::CryptoEntityClient;
|
|
use tutasdk::entities::generated::tutanota::{
|
|
DraftCreateData, DraftData, DraftRecipient, Mail, MailBox, MailDetails,
|
|
MailSetEntry, SendDraftData, SendDraftParameters,
|
|
};
|
|
use tutasdk::folder_system::{FolderSystem, MailSetKind};
|
|
use tutasdk::services::generated::tutanota::{DraftService, SendDraftService};
|
|
use tutasdk::services::ExtraServiceParams;
|
|
use tutasdk::{ApiCallError, CustomId, IdTupleGenerated, ListLoadDirection, LoggedInSdk, Sdk};
|
|
|
|
use crate::config::Config;
|
|
use crate::mail::ParsedMessage;
|
|
|
|
#[async_trait::async_trait]
|
|
pub trait MailBackend: Send + Sync {
|
|
async fn load_mail_ids_for_folder(&self, kind: MailSetKind, limit: usize) -> Result<Vec<Mail>, String>;
|
|
async fn load_mail_details(&self, mail: &Mail) -> Result<Option<MailDetails>, String>;
|
|
async fn load_folder_list(&self) -> Result<Vec<(String, String)>, String>;
|
|
async fn set_unread_status(&self, mail_ids: Vec<IdTupleGenerated>, unread: bool) -> Result<(), String>;
|
|
async fn trash_mails(&self, mail_ids: Vec<IdTupleGenerated>) -> Result<(), String>;
|
|
async fn send_mail(&self, msg: &ParsedMessage) -> Result<(), String>;
|
|
}
|
|
|
|
pub struct TutaSession {
|
|
pub logged_in: Arc<LoggedInSdk>,
|
|
pub email: String,
|
|
}
|
|
|
|
impl TutaSession {
|
|
pub async fn load_mailbox(&self) -> Result<MailBox, ApiCallError> {
|
|
self.logged_in.mail_facade().load_user_mailbox().await
|
|
}
|
|
|
|
pub async fn load_folders(&self, mailbox: &MailBox) -> Result<FolderSystem, ApiCallError> {
|
|
self.logged_in
|
|
.mail_facade()
|
|
.load_folders_for_mailbox(mailbox)
|
|
.await
|
|
}
|
|
|
|
pub async fn derive_storage_key(&self) -> Result<GenericAesKey, String> {
|
|
let user_group_id = self.logged_in.get_user_group_id();
|
|
let versioned_key = self
|
|
.logged_in
|
|
.get_current_sym_group_key(&user_group_id)
|
|
.await
|
|
.map_err(|e| format!("Failed to get user group key: {e}"))?;
|
|
let derived = blake3_kdf(
|
|
&[versioned_key.object.as_bytes()],
|
|
"tutabridge local storage v1",
|
|
AES_256_KEY_SIZE,
|
|
);
|
|
GenericAesKey::from_bytes(&derived)
|
|
.map_err(|e| format!("Key derivation error: {e:?}"))
|
|
}
|
|
|
|
fn crypto_client(&self) -> Arc<CryptoEntityClient> {
|
|
self.logged_in.mail_facade().get_crypto_entity_client()
|
|
}
|
|
|
|
async fn load_mail_ids_for_folder_impl(
|
|
&self,
|
|
folder_kind: MailSetKind,
|
|
limit: usize,
|
|
) -> Result<Vec<Mail>, ApiCallError> {
|
|
let mailbox = self.load_mailbox().await?;
|
|
let folders = self.load_folders(&mailbox).await?;
|
|
let folder = folders
|
|
.system_folder_by_type(folder_kind)
|
|
.ok_or_else(|| ApiCallError::internal(format!("Folder {:?} not found", folder_kind)))?;
|
|
|
|
let count = if limit == 0 { 1000 } else { limit };
|
|
let entries_list_id = &folder.entries;
|
|
let entries: Vec<MailSetEntry> = self
|
|
.crypto_client()
|
|
.load_range(
|
|
entries_list_id,
|
|
&CustomId::default(),
|
|
count,
|
|
ListLoadDirection::DESC,
|
|
)
|
|
.await?;
|
|
|
|
// Group entries by list_id for batch loading
|
|
let mut by_list: std::collections::HashMap<String, Vec<tutasdk::GeneratedId>> =
|
|
std::collections::HashMap::new();
|
|
for entry in &entries {
|
|
by_list
|
|
.entry(entry.mail.list_id.to_string())
|
|
.or_default()
|
|
.push(entry.mail.element_id.clone());
|
|
}
|
|
|
|
let mut mails = Vec::new();
|
|
for (list_id_str, element_ids) in &by_list {
|
|
let list_id = tutasdk::GeneratedId(list_id_str.clone());
|
|
match self
|
|
.crypto_client()
|
|
.load_multiple::<Mail>(&list_id, element_ids)
|
|
.await
|
|
{
|
|
Ok(batch) => mails.extend(batch),
|
|
Err(e) => log::warn!("Failed to batch load mails from list {}: {}", list_id_str, e),
|
|
}
|
|
}
|
|
|
|
Ok(mails)
|
|
}
|
|
|
|
async fn load_mail_details_impl(
|
|
&self,
|
|
mail: &Mail,
|
|
) -> Result<Option<MailDetails>, ApiCallError> {
|
|
if mail.mailDetails.is_some() {
|
|
match self.logged_in.mail_facade().load_mail_details_blob(mail).await {
|
|
Ok(details) => Ok(Some(details)),
|
|
Err(e) => {
|
|
log::error!("Failed to load mail details blob: {e}");
|
|
Err(e)
|
|
}
|
|
}
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
async fn send_mail_impl(&self, msg: &ParsedMessage) -> Result<(), ApiCallError> {
|
|
let randomizer = RandomizerFacade::from_core(rand_core::OsRng);
|
|
let session_key: GenericAesKey = Aes256Key::generate(&randomizer).into();
|
|
|
|
let mail_group_id = self
|
|
.logged_in
|
|
.mail_facade()
|
|
.get_group_id_for_mail_address(&self.email)
|
|
.await?;
|
|
let group_key = self
|
|
.logged_in
|
|
.get_current_sym_group_key(&mail_group_id)
|
|
.await?;
|
|
|
|
let owner_enc_session_key =
|
|
group_key.object.encrypt_key(&session_key, Iv::generate(&randomizer));
|
|
let owner_key_version = group_key.version as i64;
|
|
|
|
let draft_data = build_draft_data(msg, &self.email);
|
|
|
|
let create_data = DraftCreateData {
|
|
_format: 0,
|
|
previousMessageId: None,
|
|
conversationType: 0,
|
|
ownerEncSessionKey: owner_enc_session_key,
|
|
ownerKeyVersion: owner_key_version,
|
|
draftData: draft_data,
|
|
_errors: Default::default(),
|
|
};
|
|
|
|
let executor = self.logged_in.get_service_executor();
|
|
let draft_return = executor
|
|
.post::<DraftService>(
|
|
create_data,
|
|
ExtraServiceParams {
|
|
session_key: Some(session_key.clone()),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await?;
|
|
|
|
log::info!("Draft created: {:?}", draft_return.draft);
|
|
|
|
let parameters_id = CustomId(
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
.encode(randomizer.generate_random_array::<4>()),
|
|
);
|
|
let send_data =
|
|
build_send_draft_data(session_key.as_bytes().to_vec(), draft_return.draft, parameters_id);
|
|
|
|
let send_return = executor
|
|
.post::<SendDraftService>(send_data, ExtraServiceParams::default())
|
|
.await?;
|
|
|
|
log::info!("Mail sent, message_id: {}", send_return.messageId);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl MailBackend for TutaSession {
|
|
async fn load_mail_ids_for_folder(&self, kind: MailSetKind, limit: usize) -> Result<Vec<Mail>, String> {
|
|
self.load_mail_ids_for_folder_impl(kind, limit)
|
|
.await
|
|
.map_err(|e| format!("{e}"))
|
|
}
|
|
|
|
async fn load_mail_details(&self, mail: &Mail) -> Result<Option<MailDetails>, String> {
|
|
self.load_mail_details_impl(mail)
|
|
.await
|
|
.map_err(|e| format!("{e}"))
|
|
}
|
|
|
|
async fn load_folder_list(&self) -> Result<Vec<(String, String)>, String> {
|
|
let mailbox = self.load_mailbox().await.map_err(|e| format!("{e}"))?;
|
|
let folder_system = self.load_folders(&mailbox).await.map_err(|e| format!("{e}"))?;
|
|
|
|
let known_folders = [
|
|
(MailSetKind::Inbox, "INBOX", ""),
|
|
(MailSetKind::Sent, "Sent", "\\Sent"),
|
|
(MailSetKind::Draft, "Drafts", "\\Drafts"),
|
|
(MailSetKind::Trash, "Trash", "\\Trash"),
|
|
(MailSetKind::Archive, "Archive", "\\Archive"),
|
|
(MailSetKind::Spam, "Spam", "\\Junk"),
|
|
];
|
|
|
|
let mut result = Vec::new();
|
|
for (kind, name, flags) in &known_folders {
|
|
if folder_system.system_folder_by_type(*kind).is_some() {
|
|
result.push((name.to_string(), flags.to_string()));
|
|
}
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
async fn set_unread_status(
|
|
&self,
|
|
mail_ids: Vec<IdTupleGenerated>,
|
|
unread: bool,
|
|
) -> Result<(), String> {
|
|
self.logged_in
|
|
.mail_facade()
|
|
.set_unread_status_for_mails(mail_ids, unread)
|
|
.await
|
|
.map_err(|e| format!("{e}"))
|
|
}
|
|
|
|
async fn trash_mails(&self, mail_ids: Vec<IdTupleGenerated>) -> Result<(), String> {
|
|
self.logged_in
|
|
.mail_facade()
|
|
.trash_mails(mail_ids)
|
|
.await
|
|
.map_err(|e| format!("{e}"))
|
|
}
|
|
|
|
async fn send_mail(&self, msg: &ParsedMessage) -> Result<(), String> {
|
|
self.send_mail_impl(msg).await.map_err(|e| format!("{e}"))
|
|
}
|
|
}
|
|
|
|
struct DiskFileClient {
|
|
base_dir: std::path::PathBuf,
|
|
}
|
|
|
|
impl DiskFileClient {
|
|
fn new() -> Self {
|
|
let base_dir = dirs::cache_dir()
|
|
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
|
.join("tutabridge");
|
|
std::fs::create_dir_all(&base_dir).ok();
|
|
Self { base_dir }
|
|
}
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl FileClient for DiskFileClient {
|
|
async fn persist_content(&self, name: String, content: Vec<u8>) -> Result<(), FileClientError> {
|
|
let path = self.base_dir.join(&name);
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| FileClientError::from(e.kind()))?;
|
|
}
|
|
std::fs::write(&path, &content).map_err(|e| FileClientError::from(e.kind()))
|
|
}
|
|
|
|
async fn read_content(&self, name: String) -> Result<Vec<u8>, FileClientError> {
|
|
let path = self.base_dir.join(&name);
|
|
std::fs::read(&path).map_err(|e| FileClientError::from(e.kind()))
|
|
}
|
|
}
|
|
|
|
pub enum TwoFactorCallback {
|
|
Totp(Box<dyn Fn() -> Result<u32, Box<dyn std::error::Error + Send + Sync>> + Send + Sync>),
|
|
}
|
|
|
|
pub async fn login(
|
|
cfg: &Config,
|
|
password: &str,
|
|
) -> Result<TutaSession, Box<dyn std::error::Error + Send + Sync>> {
|
|
login_with_2fa(cfg, Some(password), None).await
|
|
}
|
|
|
|
pub async fn login_with_2fa(
|
|
cfg: &Config,
|
|
password: Option<&str>,
|
|
totp_callback: Option<TwoFactorCallback>,
|
|
) -> Result<TutaSession, Box<dyn std::error::Error + Send + Sync>> {
|
|
let rest_client: Arc<dyn RestClient> =
|
|
Arc::new(tutasdk::net::native_rest_client::NativeRestClient::try_new()?);
|
|
let file_client: Arc<dyn FileClient> = Arc::new(DiskFileClient::new());
|
|
let sdk = Sdk::new(cfg.api_url.clone(), rest_client, file_client);
|
|
|
|
if let Some(credentials) = load_credentials(&cfg.email) {
|
|
log::info!("Resuming saved session...");
|
|
match sdk.login(credentials).await {
|
|
Ok(logged_in) => {
|
|
return Ok(TutaSession {
|
|
logged_in,
|
|
email: cfg.email.clone(),
|
|
});
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Session expired, re-authenticating: {e}");
|
|
delete_credentials(&cfg.email);
|
|
}
|
|
}
|
|
}
|
|
|
|
let password = password.ok_or("No saved session and no password provided")?;
|
|
|
|
log::info!("Authenticating with Tuta servers...");
|
|
let session = sdk
|
|
.initiate_session(&cfg.email, password)
|
|
.await
|
|
.map_err(|e| {
|
|
Box::<dyn std::error::Error + Send + Sync>::from(format!("Login failed: {e}"))
|
|
})?;
|
|
let credentials = session.credentials;
|
|
let access_token = credentials.access_token.clone();
|
|
|
|
if !session.challenges.is_empty() {
|
|
for c in &session.challenges {
|
|
log::info!("2FA challenge: type={}, id={:?}", c.r#type, c._id);
|
|
}
|
|
|
|
let has_totp = session
|
|
.challenges
|
|
.iter()
|
|
.any(|c| c.r#type == i64::from(tutasdk::tutanota_constants::SecondFactorType::Totp));
|
|
|
|
if !has_totp {
|
|
return Err("Account requires U2F/WebAuthn 2FA which is not supported — only TOTP is supported".into());
|
|
}
|
|
|
|
let totp_code = match &totp_callback {
|
|
Some(TwoFactorCallback::Totp(cb)) => cb()?,
|
|
None => return Err("2FA required but no TOTP callback provided".into()),
|
|
};
|
|
sdk.authenticate_with_second_factor_totp(&access_token, totp_code)
|
|
.await
|
|
.map_err(|e| {
|
|
Box::<dyn std::error::Error + Send + Sync>::from(format!("2FA failed: {e}"))
|
|
})?;
|
|
|
|
let mut cleared = false;
|
|
for _ in 0..30 {
|
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
let pending = sdk
|
|
.is_second_factor_pending(&access_token)
|
|
.await
|
|
.map_err(|e| {
|
|
Box::<dyn std::error::Error + Send + Sync>::from(format!("2FA poll failed: {e}"))
|
|
})?;
|
|
if !pending {
|
|
cleared = true;
|
|
break;
|
|
}
|
|
}
|
|
if !cleared {
|
|
return Err("2FA verification timed out after 30 seconds".into());
|
|
}
|
|
}
|
|
|
|
let logged_in = sdk.login(credentials.clone()).await.map_err(|e| {
|
|
Box::<dyn std::error::Error + Send + Sync>::from(format!("Login failed: {e}"))
|
|
})?;
|
|
|
|
save_credentials(&cfg.email, &credentials);
|
|
|
|
Ok(TutaSession {
|
|
logged_in,
|
|
email: cfg.email.clone(),
|
|
})
|
|
}
|
|
|
|
const KEYRING_SERVICE: &str = "tutabridge";
|
|
|
|
use std::sync::Mutex;
|
|
static CREDENTIALS_CACHE: Mutex<Option<Option<tutasdk::login::Credentials>>> = Mutex::new(None);
|
|
|
|
pub fn has_saved_session(email: &str) -> bool {
|
|
load_credentials(email).is_some()
|
|
}
|
|
|
|
fn save_credentials(email: &str, creds: &tutasdk::login::Credentials) {
|
|
let data = serde_json::json!({
|
|
"login": creds.login,
|
|
"user_id": creds.user_id.0,
|
|
"access_token": creds.access_token,
|
|
"encrypted_passphrase_key": base64::Engine::encode(
|
|
&base64::engine::general_purpose::STANDARD,
|
|
&creds.encrypted_passphrase_key,
|
|
),
|
|
"credential_type": match creds.credential_type {
|
|
tutasdk::login::CredentialType::Internal => "Internal",
|
|
tutasdk::login::CredentialType::External => "External",
|
|
},
|
|
});
|
|
|
|
match keyring::Entry::new(KEYRING_SERVICE, email) {
|
|
Ok(entry) => {
|
|
if let Err(e) = entry.set_password(&data.to_string()) {
|
|
log::warn!("Failed to save session to keychain: {e}");
|
|
} else {
|
|
log::info!("Session saved to keychain");
|
|
}
|
|
}
|
|
Err(e) => log::warn!("Failed to create keychain entry: {e}"),
|
|
}
|
|
*CREDENTIALS_CACHE.lock().unwrap() = Some(Some(creds.clone()));
|
|
}
|
|
|
|
fn load_credentials(email: &str) -> Option<tutasdk::login::Credentials> {
|
|
let mut cache = CREDENTIALS_CACHE.lock().unwrap();
|
|
if let Some(cached) = cache.as_ref() {
|
|
|
|
return cached.clone();
|
|
}
|
|
|
|
|
|
let result = load_credentials_from_keyring(email);
|
|
*cache = Some(result.clone());
|
|
result
|
|
}
|
|
|
|
fn load_credentials_from_keyring(email: &str) -> Option<tutasdk::login::Credentials> {
|
|
|
|
let entry = keyring::Entry::new(KEYRING_SERVICE, email).ok()?;
|
|
|
|
let json_str = entry.get_password().ok()?;
|
|
|
|
let v: serde_json::Value = serde_json::from_str(&json_str).ok()?;
|
|
Some(tutasdk::login::Credentials {
|
|
login: v["login"].as_str()?.to_string(),
|
|
user_id: tutasdk::GeneratedId(v["user_id"].as_str()?.to_string()),
|
|
access_token: v["access_token"].as_str()?.to_string(),
|
|
encrypted_passphrase_key: base64::Engine::decode(
|
|
&base64::engine::general_purpose::STANDARD,
|
|
v["encrypted_passphrase_key"].as_str()?,
|
|
)
|
|
.ok()?,
|
|
credential_type: match v["credential_type"].as_str()? {
|
|
"External" => tutasdk::login::CredentialType::External,
|
|
_ => tutasdk::login::CredentialType::Internal,
|
|
},
|
|
})
|
|
}
|
|
|
|
fn delete_credentials(email: &str) {
|
|
|
|
if let Ok(entry) = keyring::Entry::new(KEYRING_SERVICE, email) {
|
|
let _ = entry.delete_credential();
|
|
}
|
|
*CREDENTIALS_CACHE.lock().unwrap() = None;
|
|
}
|
|
|
|
|
|
/// Map SMTP recipients to `DraftRecipient`, falling back to the address when
|
|
/// the display name is empty (Tuta's send service rejects empty names).
|
|
fn build_draft_recipients(recipients: &[(String, String)]) -> Vec<DraftRecipient> {
|
|
recipients
|
|
.iter()
|
|
.map(|(name, addr)| DraftRecipient {
|
|
_id: None,
|
|
name: if name.is_empty() {
|
|
addr.clone()
|
|
} else {
|
|
name.clone()
|
|
},
|
|
mailAddress: addr.clone(),
|
|
_errors: Default::default(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Build the `DraftData` for a draft creation from a parsed SMTP message.
|
|
///
|
|
/// Mirrors the web client: the body goes into both `bodyText` and
|
|
/// `compressedBodyText`, and empty sender/recipient names fall back to the
|
|
/// address (an empty name makes `SendDraftService` fail).
|
|
fn build_draft_data(msg: &ParsedMessage, sender_email: &str) -> DraftData {
|
|
DraftData {
|
|
_id: None,
|
|
subject: msg.subject.clone(),
|
|
bodyText: msg.body_html.clone(),
|
|
senderMailAddress: sender_email.to_string(),
|
|
senderName: if msg.from_name.is_empty() {
|
|
sender_email.to_string()
|
|
} else {
|
|
msg.from_name.clone()
|
|
},
|
|
confidential: false,
|
|
method: 0,
|
|
compressedBodyText: Some(msg.body_html.clone()),
|
|
toRecipients: build_draft_recipients(&msg.to),
|
|
ccRecipients: build_draft_recipients(&msg.cc),
|
|
bccRecipients: build_draft_recipients(&msg.bcc),
|
|
addedAttachments: vec![],
|
|
removedAttachments: vec![],
|
|
replyTos: vec![],
|
|
_errors: Default::default(),
|
|
}
|
|
}
|
|
|
|
/// Build the `SendDraftData` for sending a previously created draft.
|
|
///
|
|
/// The session data is mirrored into the nested `parameters` aggregate (with a
|
|
/// generated `_id`), which the current server model reads; `plaintext` is
|
|
/// `false` (it reflects the account's plaintext-only setting, not whether the
|
|
/// mail is encrypted). Recipient key arrays stay empty for a non-confidential
|
|
/// send.
|
|
fn build_send_draft_data(
|
|
session_key_bytes: Vec<u8>,
|
|
draft_id: IdTupleGenerated,
|
|
parameters_id: CustomId,
|
|
) -> SendDraftData {
|
|
SendDraftData {
|
|
_format: 0,
|
|
language: "en".to_string(),
|
|
mailSessionKey: Some(session_key_bytes.clone()),
|
|
bucketEncMailSessionKey: None,
|
|
senderNameUnencrypted: None,
|
|
plaintext: false,
|
|
calendarMethod: false,
|
|
sessionEncEncryptionAuthStatus: None,
|
|
sendAt: None,
|
|
allowUndo: false,
|
|
internalRecipientKeyData: vec![],
|
|
secureExternalRecipientKeyData: vec![],
|
|
attachmentKeyData: vec![],
|
|
mail: draft_id.clone(),
|
|
symEncInternalRecipientKeyData: vec![],
|
|
parameters: Some(SendDraftParameters {
|
|
_id: Some(parameters_id),
|
|
language: "en".to_string(),
|
|
mailSessionKey: Some(session_key_bytes),
|
|
bucketEncMailSessionKey: None,
|
|
senderNameUnencrypted: None,
|
|
plaintext: false,
|
|
calendarMethod: false,
|
|
sessionEncEncryptionAuthStatus: None,
|
|
mail: draft_id,
|
|
internalRecipientKeyData: vec![],
|
|
secureExternalRecipientKeyData: vec![],
|
|
symEncInternalRecipientKeyData: vec![],
|
|
attachmentKeyData: vec![],
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod send_tests {
|
|
use super::*;
|
|
|
|
fn sample_msg() -> ParsedMessage {
|
|
ParsedMessage {
|
|
from_address: "me@tuta.io".to_string(),
|
|
from_name: "Me".to_string(),
|
|
to: vec![("Bob".to_string(), "bob@example.com".to_string())],
|
|
cc: vec![],
|
|
bcc: vec![],
|
|
subject: "Hi".to_string(),
|
|
body_html: "<p>hello</p>".to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn draft_data_puts_body_in_both_fields() {
|
|
let d = build_draft_data(&sample_msg(), "me@tuta.io");
|
|
assert_eq!(d.bodyText, "<p>hello</p>");
|
|
assert_eq!(d.compressedBodyText.as_deref(), Some("<p>hello</p>"));
|
|
assert!(!d.confidential);
|
|
assert_eq!(d.method, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn draft_data_empty_sender_name_falls_back_to_address() {
|
|
let mut msg = sample_msg();
|
|
msg.from_name = String::new();
|
|
let d = build_draft_data(&msg, "me@tuta.io");
|
|
assert_eq!(d.senderName, "me@tuta.io");
|
|
}
|
|
|
|
#[test]
|
|
fn draft_data_keeps_non_empty_sender_name() {
|
|
let d = build_draft_data(&sample_msg(), "me@tuta.io");
|
|
assert_eq!(d.senderName, "Me");
|
|
}
|
|
|
|
#[test]
|
|
fn recipient_empty_name_falls_back_to_address() {
|
|
let recips = build_draft_recipients(&[(String::new(), "x@example.com".to_string())]);
|
|
assert_eq!(recips[0].name, "x@example.com");
|
|
assert_eq!(recips[0].mailAddress, "x@example.com");
|
|
}
|
|
|
|
#[test]
|
|
fn recipient_keeps_non_empty_name() {
|
|
let recips = build_draft_recipients(&[("Alice".to_string(), "a@example.com".to_string())]);
|
|
assert_eq!(recips[0].name, "Alice");
|
|
}
|
|
|
|
#[test]
|
|
fn send_draft_data_mirrors_parameters_and_is_not_plaintext() {
|
|
let draft_id = IdTupleGenerated::new(
|
|
tutasdk::GeneratedId("list".to_string()),
|
|
tutasdk::GeneratedId("elem".to_string()),
|
|
);
|
|
let pid = CustomId("aggId".to_string());
|
|
let sk = vec![1u8, 2, 3, 4];
|
|
let sd = build_send_draft_data(sk.clone(), draft_id.clone(), pid.clone());
|
|
|
|
// top-level
|
|
assert!(!sd.plaintext);
|
|
assert_eq!(sd.mailSessionKey.as_deref(), Some(sk.as_slice()));
|
|
assert!(sd.bucketEncMailSessionKey.is_none());
|
|
assert!(sd.internalRecipientKeyData.is_empty());
|
|
assert!(sd.secureExternalRecipientKeyData.is_empty());
|
|
assert!(sd.symEncInternalRecipientKeyData.is_empty());
|
|
assert_eq!(sd.mail, draft_id);
|
|
|
|
// nested parameters must be populated (None causes a 500 server-side)
|
|
let p = sd.parameters.expect("parameters must be set");
|
|
assert_eq!(p._id, Some(pid));
|
|
assert!(!p.plaintext);
|
|
assert_eq!(p.mailSessionKey.as_deref(), Some(sk.as_slice()));
|
|
assert_eq!(p.mail, draft_id);
|
|
}
|
|
}
|