Skip to content

Commit 2177126

Browse files
committed
peergos-fs: add email protocol (message, crypto, client) with MIME type
1 parent fc84b8a commit 2177126

6 files changed

Lines changed: 811 additions & 0 deletions

File tree

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
//! The user-side email client, a faithful port of
2+
//! `peergos.shared.email.EmailClient`.
3+
//!
4+
//! All email data lives under `/$username/.apps/email/data/default/`. The
5+
//! bridge has write access to `pending/` via a writable secret link; the client
6+
//! encrypts/decrypts with a Curve25519 [`BoxingKeyPair`] stored in
7+
//! `encryption.keypair.cbor`.
8+
9+
use crate::context::UserContext;
10+
use crate::email::crypto::SourcedAsymmetricCipherText;
11+
use crate::email::message::{Attachment, EmailMessage};
12+
use crate::filewrapper::FileWrapper;
13+
use peergos_cbor::{CborObject, Cborable};
14+
use peergos_core::boxing::BoxingKeyPair;
15+
use peergos_core::error::{Error, Result};
16+
17+
const ACCOUNT: &str = "default";
18+
const KEYPAIR_PATH: &str = "encryption.keypair.cbor";
19+
const PUBLIC_KEY_FILENAME: &str = "encryption.publickey.cbor";
20+
const CLIENT_EMAIL_FILENAME: &str = "email.json";
21+
22+
/// Directories created during initialisation (`EmailClient.initialise`).
23+
const DIRS: &[&str] = &[
24+
"inbox",
25+
"sent",
26+
"pending",
27+
"attachments",
28+
"pending/inbox",
29+
"pending/outbox",
30+
"pending/sent",
31+
"pending/inbox/attachments",
32+
"pending/outbox/attachments",
33+
"pending/sent/attachments",
34+
];
35+
36+
/// The user-side email manager (`EmailClient`).
37+
pub struct EmailClient {
38+
pub encryption_keys: BoxingKeyPair,
39+
email_root: FileWrapper,
40+
}
41+
42+
impl EmailClient {
43+
// ------------------------------------------------------------------
44+
// Construction
45+
// ------------------------------------------------------------------
46+
47+
/// Initialise the email app: create the directory tree, generate a fresh
48+
/// [`BoxingKeyPair`], and store the public key for the bridge
49+
/// (`EmailClient.initialise`).
50+
pub async fn initialise(ctx: &UserContext) -> Result<EmailClient> {
51+
let home = ctx.get_home().await?;
52+
let email_root = home.get_or_mkdirs(".apps/email/data").await?;
53+
54+
// Create all sub-directories.
55+
for d in DIRS {
56+
email_root.get_or_mkdirs(&format!("{ACCOUNT}/{d}")).await?;
57+
}
58+
59+
let keys = BoxingKeyPair::random_curve25519();
60+
61+
// Store the full keypair.
62+
let default_dir = email_root.child(ACCOUNT).await?
63+
.ok_or_else(|| Error::Protocol("default email dir missing".into()))?;
64+
default_dir.upload(KEYPAIR_PATH, &keys.to_cbor().to_bytes()).await?;
65+
66+
// Store the public key for the bridge.
67+
let pending = default_dir.child("pending").await?
68+
.ok_or_else(|| Error::Protocol("pending dir missing".into()))?;
69+
pending.upload(PUBLIC_KEY_FILENAME, &keys.public.to_cbor().to_bytes()).await?;
70+
71+
Ok(EmailClient { encryption_keys: keys, email_root })
72+
}
73+
74+
/// Load an existing email client, or initialise if not yet set up
75+
/// (`EmailClient.load`).
76+
pub async fn load(ctx: &UserContext) -> Result<EmailClient> {
77+
let home = ctx.get_home().await?;
78+
let email_root = match home.child(".apps/email/data").await? {
79+
Some(r) => r,
80+
None => return Self::initialise(ctx).await,
81+
};
82+
let default_dir = email_root.child(ACCOUNT).await?
83+
.ok_or_else(|| Error::Protocol("default email dir missing".into()))?;
84+
85+
match default_dir.child(KEYPAIR_PATH).await? {
86+
Some(f) => {
87+
let bytes = f.read().await?;
88+
let cbor = CborObject::from_bytes(&bytes)?;
89+
let keys = BoxingKeyPair::from_cbor(&cbor)?;
90+
Ok(EmailClient { encryption_keys: keys, email_root })
91+
}
92+
None => Self::initialise(ctx).await,
93+
}
94+
}
95+
96+
// ------------------------------------------------------------------
97+
// Helpers
98+
// ------------------------------------------------------------------
99+
100+
/// The `default` sub-directory of the email root.
101+
async fn default_dir(&self) -> Result<FileWrapper> {
102+
self.email_root.child(ACCOUNT).await?
103+
.ok_or_else(|| Error::Protocol("default email dir missing".into()))
104+
}
105+
106+
/// The `pending` sub-directory.
107+
async fn pending_dir(&self) -> Result<FileWrapper> {
108+
self.default_dir().await?.child("pending").await?
109+
.ok_or_else(|| Error::Protocol("pending dir missing".into()))
110+
}
111+
112+
/// The `attachments` sub-directory.
113+
async fn attachments_dir(&self) -> Result<FileWrapper> {
114+
self.default_dir().await?.child("attachments").await?
115+
.ok_or_else(|| Error::Protocol("attachments dir missing".into()))
116+
}
117+
118+
/// Decrypt a `SourcedAsymmetricCipherText` to an `EmailMessage`.
119+
async fn decrypt_email(&self, ct: &SourcedAsymmetricCipherText) -> Result<EmailMessage> {
120+
let bytes = ct.decrypt(&self.encryption_keys.secret)?;
121+
let cbor = CborObject::from_bytes(&bytes)?;
122+
EmailMessage::from_cbor(&cbor)
123+
}
124+
125+
/// Decrypt a `SourcedAsymmetricCipherText` to raw bytes (for attachments).
126+
async fn decrypt_attachment(&self, ct: &SourcedAsymmetricCipherText) -> Result<Vec<u8>> {
127+
ct.decrypt(&self.encryption_keys.secret)
128+
}
129+
130+
/// List `.cbor` files in a directory, decrypt each, and return the emails
131+
/// (`EmailClient.listFiles`).
132+
async fn list_encrypted_emails(&self, dir: &FileWrapper) -> Result<Vec<EmailMessage>> {
133+
let children = dir.children().await?;
134+
let mut emails = Vec::new();
135+
for child in &children {
136+
if child.name().ends_with(".cbor") {
137+
let bytes = child.read().await?;
138+
let cbor = CborObject::from_bytes(&bytes)?;
139+
let ct = SourcedAsymmetricCipherText::from_cbor(&cbor)?;
140+
match self.decrypt_email(&ct).await {
141+
Ok(msg) => emails.push(msg),
142+
Err(_) => continue,
143+
}
144+
}
145+
}
146+
Ok(emails)
147+
}
148+
149+
/// Write an email message to a folder as a `.cbor` file
150+
/// (`EmailClient.saveEmail`).
151+
async fn save_email(&self, folder: &str, msg: &EmailMessage) -> Result<()> {
152+
let dir = self.default_dir().await?.get_or_mkdirs(folder).await?;
153+
let filename = format!("{}.cbor", msg.id);
154+
dir.upload(&filename, &msg.serialize()).await?;
155+
Ok(())
156+
}
157+
158+
/// Move attachments from a pending folder to a private folder, decrypting
159+
/// any that are wrapped in `SourcedAsymmetricCipherText`.
160+
async fn move_attachments_to_private(
161+
&self,
162+
attachments: &[Attachment],
163+
pending_folder: &str,
164+
) -> Result<()> {
165+
let default = self.default_dir().await?;
166+
let private_attachments = default.child("attachments").await?
167+
.ok_or_else(|| Error::Protocol("attachments dir missing".into()))?;
168+
169+
for att in attachments {
170+
let src_path = format!("pending/{pending_folder}/attachments/{}", att.uuid);
171+
let dest_name = &att.uuid;
172+
173+
// If the destination already exists, skip.
174+
if private_attachments.child(dest_name).await?.is_some() {
175+
continue;
176+
}
177+
178+
let src = match self.email_root.get_by_path(&format!("{ACCOUNT}/{src_path}")).await? {
179+
Some(f) => f,
180+
None => continue,
181+
};
182+
let bytes = src.read().await?;
183+
let cbor = CborObject::from_bytes(&bytes)?;
184+
let ct = SourcedAsymmetricCipherText::from_cbor(&cbor)?;
185+
let decrypted = self.decrypt_attachment(&ct).await?;
186+
private_attachments.upload(dest_name, &decrypted).await?;
187+
188+
// Delete the source.
189+
if let Some(parent_path) = src_path.rsplit_once('/') {
190+
if let Some(parent) = self.email_root.get_by_path(&format!("{ACCOUNT}/{}", parent_path.0)).await? {
191+
let _ = parent.remove_child(dest_name).await;
192+
}
193+
}
194+
}
195+
Ok(())
196+
}
197+
198+
/// Move an email file from a pending path to the private folder, writing
199+
/// the decrypted CBOR and deleting the original.
200+
async fn move_to_private_dir(&self, dest_folder: &str, msg: &EmailMessage, src_relative: &str) -> Result<()> {
201+
let default = self.default_dir().await?;
202+
let dest = default.get_or_mkdirs(dest_folder).await?;
203+
let filename = format!("{}.cbor", msg.id);
204+
dest.upload(&filename, &msg.serialize()).await?;
205+
206+
// Delete the source file.
207+
if let Some(parent) = self.email_root.get_by_path(
208+
&format!("{ACCOUNT}/{}", src_relative.rsplit_once('/').map(|(p, _)| p).unwrap_or("")),
209+
).await? {
210+
let _ = parent.remove_child(&filename).await;
211+
}
212+
Ok(())
213+
}
214+
215+
// ------------------------------------------------------------------
216+
// Public API
217+
// ------------------------------------------------------------------
218+
219+
/// Upload an attachment to the outbox and return its UUID
220+
/// (`EmailClient.uploadAttachment`).
221+
pub async fn upload_attachment(&self, data: &[u8]) -> Result<String> {
222+
let uuid = uuid_v4();
223+
let pending = self.pending_dir().await?;
224+
let outbox = pending.get_or_mkdirs("outbox/attachments").await?;
225+
outbox.upload(&uuid, data).await?;
226+
Ok(uuid)
227+
}
228+
229+
/// Send an email: move forwarded attachments to the outbox and save the
230+
/// email to `pending/outbox/{id}.cbor` (`EmailClient.send`).
231+
pub async fn send(&self, msg: &EmailMessage) -> Result<()> {
232+
// Upload forwarded attachments if present.
233+
if let Some(fwd) = &msg.forwarding_to_email {
234+
for att in &fwd.attachments {
235+
let src_path = format!("{ACCOUNT}/default/attachments/{}", att.uuid);
236+
if let Some(src) = self.email_root.get_by_path(&src_path).await? {
237+
let bytes = src.read().await?;
238+
let dest = self.pending_dir().await?.get_or_mkdirs("outbox/attachments").await?;
239+
dest.upload(&att.uuid, &bytes).await?;
240+
}
241+
}
242+
}
243+
self.save_email("pending/outbox", msg).await
244+
}
245+
246+
/// Retrieve and decrypt new incoming emails from the bridge
247+
/// (`EmailClient.getNewIncoming`).
248+
pub async fn get_new_incoming(&self) -> Result<Vec<EmailMessage>> {
249+
let pending = self.pending_dir().await?;
250+
let inbox = pending.child("inbox").await?
251+
.ok_or_else(|| Error::Protocol("pending/inbox dir missing".into()))?;
252+
self.list_encrypted_emails(&inbox).await
253+
}
254+
255+
/// Retrieve and decrypt sent email confirmations from the bridge
256+
/// (`EmailClient.getNewSent`).
257+
pub async fn get_new_sent(&self) -> Result<Vec<EmailMessage>> {
258+
let pending = self.pending_dir().await?;
259+
let sent = pending.child("sent").await?
260+
.ok_or_else(|| Error::Protocol("pending/sent dir missing".into()))?;
261+
self.list_encrypted_emails(&sent).await
262+
}
263+
264+
/// Read an attachment by UUID from the private attachments directory
265+
/// (`EmailClient.getAttachment`).
266+
pub async fn get_attachment(&self, uid: &str) -> Result<Vec<u8>> {
267+
let attachments = self.attachments_dir().await?;
268+
let file = attachments.child(uid).await?
269+
.ok_or_else(|| Error::Protocol(format!("attachment {uid} not found")))?;
270+
file.read().await
271+
}
272+
273+
/// Move a received email from `pending/inbox` to the private `inbox`,
274+
/// decrypting its attachments along the way
275+
/// (`EmailClient.moveToPrivateInbox`).
276+
pub async fn move_to_private_inbox(&self, msg: &EmailMessage) -> Result<()> {
277+
self.move_attachments_to_private(&msg.attachments, "inbox").await?;
278+
let src = format!("{}/pending/inbox/{}.cbor", ACCOUNT, msg.id);
279+
self.move_to_private_dir("inbox", msg, &src).await
280+
}
281+
282+
/// Move a sent email from `pending/sent` to the private `sent` folder
283+
/// (`EmailClient.moveToPrivateSent`).
284+
pub async fn move_to_private_sent(&self, msg: &EmailMessage) -> Result<()> {
285+
self.move_attachments_to_private(&msg.attachments, "sent").await?;
286+
let src = format!("{}/pending/sent/{}.cbor", ACCOUNT, msg.id);
287+
self.move_to_private_dir("sent", msg, &src).await
288+
}
289+
290+
/// Read the email address the bridge has written for us
291+
/// (`EmailClient.getEmailAddress`).
292+
pub async fn get_email_address(&self) -> Result<Option<String>> {
293+
let pending = self.pending_dir().await?;
294+
let file = match pending.child(CLIENT_EMAIL_FILENAME).await? {
295+
Some(f) => f,
296+
None => return Ok(None),
297+
};
298+
let bytes = file.read().await?;
299+
// Parse as JSON: {"email": "user@example.com"}
300+
let text = String::from_utf8(bytes).map_err(|_| Error::Protocol("email.json not UTF-8".into()))?;
301+
// Simple JSON extraction — the bridge writes `{ "email": "..." }`.
302+
Ok(parse_email_json(&text))
303+
}
304+
305+
/// Create a writable secret link for the `pending` directory so the bridge
306+
/// can access it (`EmailClient.connectToBridge`).
307+
pub async fn connect_to_bridge(&self, ctx: &UserContext) -> Result<String> {
308+
let pending_path = format!("/{}/.apps/email/data/{}/pending",
309+
ctx.username().ok_or_else(|| Error::Protocol("requires signed-in user".into()))?,
310+
ACCOUNT);
311+
ctx.create_secret_link(&pending_path, true, "", None, None).await
312+
}
313+
}
314+
315+
/// Parse the email address from the bridge's `email.json` (`{"email":"..."}`)
316+
fn parse_email_json(text: &str) -> Option<String> {
317+
let marker = "\"email\"";
318+
let key_pos = text.find(marker)?;
319+
let rest = &text[key_pos + marker.len()..];
320+
let colon = rest.find(':')?;
321+
let rest = &rest[colon + 1..];
322+
let start_quote = rest.find('"')?;
323+
let rest = &rest[start_quote + 1..];
324+
let end_quote = rest.find('"')?;
325+
Some(rest[..end_quote].to_string())
326+
}
327+
328+
/// Generate a random UUID v4 string (hyphenated lowercase).
329+
fn uuid_v4() -> String {
330+
let bytes = peergos_crypto::random_bytes(16);
331+
format!(
332+
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
333+
bytes[0], bytes[1], bytes[2], bytes[3],
334+
bytes[4], bytes[5],
335+
(bytes[6] & 0x0f) | 0x40, bytes[7],
336+
(bytes[8] & 0x3f) | 0x80, bytes[9],
337+
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
338+
)
339+
}
340+
341+
#[cfg(test)]
342+
mod tests {
343+
use super::*;
344+
345+
#[test]
346+
fn parse_email_json_test() {
347+
assert_eq!(
348+
parse_email_json("{ \"email\": \"user@example.com\"}"),
349+
Some("user@example.com".to_string())
350+
);
351+
assert_eq!(parse_email_json("nope"), None);
352+
}
353+
354+
#[test]
355+
fn uuid_v4_format() {
356+
let u = uuid_v4();
357+
assert_eq!(u.len(), 36);
358+
assert_eq!(u.chars().nth(14), Some('4')); // version nibble
359+
assert!(matches!(u.as_bytes()[19], b'8' | b'9' | b'a' | b'b')); // variant bits
360+
}
361+
}

0 commit comments

Comments
 (0)