Skip to main content

rusmes_jmap/methods/
identity.rs

1//! Identity method implementations for JMAP
2//!
3//! Implements:
4//! - Identity/get, Identity/set - sender identities
5//! - Identity/changes - identity tracking
6
7use crate::types::JmapSetError;
8use rusmes_storage::MessageStore;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Identity object
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct Identity {
16    /// Unique identifier
17    pub id: String,
18    /// Display name
19    pub name: String,
20    /// Email address
21    pub email: String,
22    /// Reply-to address
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub reply_to: Option<Vec<crate::types::EmailAddress>>,
25    /// Bcc address (auto-bcc on sends)
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub bcc: Option<Vec<crate::types::EmailAddress>>,
28    /// Text signature
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub text_signature: Option<String>,
31    /// HTML signature
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub html_signature: Option<String>,
34    /// May delete
35    pub may_delete: bool,
36}
37
38/// Identity/get request
39#[derive(Debug, Clone, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct IdentityGetRequest {
42    pub account_id: String,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub ids: Option<Vec<String>>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub properties: Option<Vec<String>>,
47}
48
49/// Identity/get response
50#[derive(Debug, Clone, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub struct IdentityGetResponse {
53    pub account_id: String,
54    pub state: String,
55    pub list: Vec<Identity>,
56    pub not_found: Vec<String>,
57}
58
59/// Identity/set request
60#[derive(Debug, Clone, Deserialize)]
61#[serde(rename_all = "camelCase")]
62pub struct IdentitySetRequest {
63    pub account_id: String,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub if_in_state: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub create: Option<HashMap<String, IdentityObject>>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub update: Option<HashMap<String, serde_json::Value>>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub destroy: Option<Vec<String>>,
72}
73
74/// Identity object for creation
75#[derive(Debug, Clone, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct IdentityObject {
78    pub name: String,
79    pub email: String,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub reply_to: Option<Vec<crate::types::EmailAddress>>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub bcc: Option<Vec<crate::types::EmailAddress>>,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub text_signature: Option<String>,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub html_signature: Option<String>,
88}
89
90/// Identity/set response
91#[derive(Debug, Clone, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct IdentitySetResponse {
94    pub account_id: String,
95    pub old_state: String,
96    pub new_state: String,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub created: Option<HashMap<String, Identity>>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub updated: Option<HashMap<String, Option<Identity>>>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub destroyed: Option<Vec<String>>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub not_created: Option<HashMap<String, JmapSetError>>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub not_updated: Option<HashMap<String, JmapSetError>>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub not_destroyed: Option<HashMap<String, JmapSetError>>,
109}
110
111/// Identity/changes request
112#[derive(Debug, Clone, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct IdentityChangesRequest {
115    pub account_id: String,
116    pub since_state: String,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub max_changes: Option<u64>,
119}
120
121/// Identity/changes response
122#[derive(Debug, Clone, Serialize)]
123#[serde(rename_all = "camelCase")]
124pub struct IdentityChangesResponse {
125    pub account_id: String,
126    pub old_state: String,
127    pub new_state: String,
128    pub has_more_changes: bool,
129    pub created: Vec<String>,
130    pub updated: Vec<String>,
131    pub destroyed: Vec<String>,
132}
133
134/// Handle Identity/get method
135pub async fn identity_get(
136    request: IdentityGetRequest,
137    _message_store: &dyn MessageStore,
138) -> anyhow::Result<IdentityGetResponse> {
139    let mut list = Vec::new();
140    let mut not_found = Vec::new();
141
142    // If no IDs specified, return default identity
143    let ids = request.ids.unwrap_or_else(|| vec!["default".to_string()]);
144
145    for id in ids {
146        if id == "default" {
147            // Return a default identity
148            list.push(Identity {
149                id: "default".to_string(),
150                name: "Default User".to_string(),
151                email: "user@example.com".to_string(),
152                reply_to: None,
153                bcc: None,
154                text_signature: None,
155                html_signature: None,
156                may_delete: false,
157            });
158        } else {
159            not_found.push(id);
160        }
161    }
162
163    Ok(IdentityGetResponse {
164        account_id: request.account_id,
165        state: "1".to_string(),
166        list,
167        not_found,
168    })
169}
170
171/// Handle Identity/set method
172#[allow(clippy::too_many_arguments)]
173pub async fn identity_set(
174    request: IdentitySetRequest,
175    _message_store: &dyn MessageStore,
176) -> anyhow::Result<IdentitySetResponse> {
177    let created = HashMap::new();
178    let updated = HashMap::new();
179    let destroyed = Vec::new();
180    let mut not_created = HashMap::new();
181    let mut not_updated = HashMap::new();
182    let mut not_destroyed = HashMap::new();
183
184    // Handle creates
185    if let Some(create_map) = request.create {
186        for (creation_id, _identity_obj) in create_map {
187            not_created.insert(
188                creation_id,
189                JmapSetError {
190                    error_type: "notImplemented".to_string(),
191                    description: Some("Identity creation not yet implemented".to_string()),
192                },
193            );
194        }
195    }
196
197    // Handle updates
198    if let Some(update_map) = request.update {
199        for (id, _patch) in update_map {
200            not_updated.insert(
201                id,
202                JmapSetError {
203                    error_type: "notImplemented".to_string(),
204                    description: Some("Identity update not yet implemented".to_string()),
205                },
206            );
207        }
208    }
209
210    // Handle destroys
211    if let Some(destroy_ids) = request.destroy {
212        for id in destroy_ids {
213            if id == "default" {
214                not_destroyed.insert(
215                    id,
216                    JmapSetError {
217                        error_type: "forbidden".to_string(),
218                        description: Some("Cannot delete default identity".to_string()),
219                    },
220                );
221            } else {
222                not_destroyed.insert(
223                    id,
224                    JmapSetError {
225                        error_type: "notImplemented".to_string(),
226                        description: Some("Identity deletion not yet implemented".to_string()),
227                    },
228                );
229            }
230        }
231    }
232
233    Ok(IdentitySetResponse {
234        account_id: request.account_id,
235        old_state: "1".to_string(),
236        new_state: "2".to_string(),
237        created: if created.is_empty() {
238            None
239        } else {
240            Some(created)
241        },
242        updated: if updated.is_empty() {
243            None
244        } else {
245            Some(updated)
246        },
247        destroyed: if destroyed.is_empty() {
248            None
249        } else {
250            Some(destroyed)
251        },
252        not_created: if not_created.is_empty() {
253            None
254        } else {
255            Some(not_created)
256        },
257        not_updated: if not_updated.is_empty() {
258            None
259        } else {
260            Some(not_updated)
261        },
262        not_destroyed: if not_destroyed.is_empty() {
263            None
264        } else {
265            Some(not_destroyed)
266        },
267    })
268}
269
270/// Handle Identity/changes method
271pub async fn identity_changes(
272    request: IdentityChangesRequest,
273    _message_store: &dyn MessageStore,
274) -> anyhow::Result<IdentityChangesResponse> {
275    let since_state: u64 = request.since_state.parse().unwrap_or(0);
276    let new_state = (since_state + 1).to_string();
277
278    Ok(IdentityChangesResponse {
279        account_id: request.account_id,
280        old_state: request.since_state,
281        new_state,
282        has_more_changes: false,
283        created: Vec::new(),
284        updated: Vec::new(),
285        destroyed: Vec::new(),
286    })
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use rusmes_storage::backends::filesystem::FilesystemBackend;
293    use rusmes_storage::StorageBackend;
294    use std::path::PathBuf;
295
296    fn create_test_store() -> std::sync::Arc<dyn MessageStore> {
297        let backend = FilesystemBackend::new(PathBuf::from("/tmp/rusmes-test-storage"));
298        backend.message_store()
299    }
300
301    #[tokio::test]
302    async fn test_identity_get() {
303        let store = create_test_store();
304        let request = IdentityGetRequest {
305            account_id: "acc1".to_string(),
306            ids: Some(vec!["default".to_string()]),
307            properties: None,
308        };
309
310        let response = identity_get(request, store.as_ref()).await.unwrap();
311        assert_eq!(response.list.len(), 1);
312        assert_eq!(response.list[0].id, "default");
313    }
314
315    #[tokio::test]
316    async fn test_identity_set_create() {
317        let store = create_test_store();
318        let mut create_map = HashMap::new();
319        create_map.insert(
320            "new1".to_string(),
321            IdentityObject {
322                name: "John Doe".to_string(),
323                email: "john@example.com".to_string(),
324                reply_to: None,
325                bcc: None,
326                text_signature: Some("Best regards,\nJohn".to_string()),
327                html_signature: None,
328            },
329        );
330
331        let request = IdentitySetRequest {
332            account_id: "acc1".to_string(),
333            if_in_state: None,
334            create: Some(create_map),
335            update: None,
336            destroy: None,
337        };
338
339        let response = identity_set(request, store.as_ref()).await.unwrap();
340        assert!(response.not_created.is_some());
341    }
342
343    #[tokio::test]
344    async fn test_identity_changes() {
345        let store = create_test_store();
346        let request = IdentityChangesRequest {
347            account_id: "acc1".to_string(),
348            since_state: "1".to_string(),
349            max_changes: Some(50),
350        };
351
352        let response = identity_changes(request, store.as_ref()).await.unwrap();
353        assert_eq!(response.old_state, "1");
354        assert_eq!(response.new_state, "2");
355    }
356
357    #[tokio::test]
358    async fn test_identity_set_destroy_default() {
359        let store = create_test_store();
360        let request = IdentitySetRequest {
361            account_id: "acc1".to_string(),
362            if_in_state: None,
363            create: None,
364            update: None,
365            destroy: Some(vec!["default".to_string()]),
366        };
367
368        let response = identity_set(request, store.as_ref()).await.unwrap();
369        assert!(response.not_destroyed.is_some());
370        let errors = response.not_destroyed.unwrap();
371        assert_eq!(errors.get("default").unwrap().error_type, "forbidden");
372    }
373
374    #[tokio::test]
375    async fn test_identity_with_signature() {
376        let store = create_test_store();
377        let mut create_map = HashMap::new();
378        create_map.insert(
379            "sig1".to_string(),
380            IdentityObject {
381                name: "Test User".to_string(),
382                email: "test@example.com".to_string(),
383                reply_to: None,
384                bcc: None,
385                text_signature: Some("--\nBest regards".to_string()),
386                html_signature: Some("<p>Best regards</p>".to_string()),
387            },
388        );
389
390        let request = IdentitySetRequest {
391            account_id: "acc1".to_string(),
392            if_in_state: None,
393            create: Some(create_map),
394            update: None,
395            destroy: None,
396        };
397
398        let response = identity_set(request, store.as_ref()).await.unwrap();
399        assert!(response.not_created.is_some());
400    }
401
402    #[tokio::test]
403    async fn test_identity_with_bcc() {
404        let store = create_test_store();
405        let mut create_map = HashMap::new();
406        let bcc = vec![crate::types::EmailAddress::new(
407            "archive@example.com".to_string(),
408        )];
409
410        create_map.insert(
411            "bcc1".to_string(),
412            IdentityObject {
413                name: "Test User".to_string(),
414                email: "test@example.com".to_string(),
415                reply_to: None,
416                bcc: Some(bcc),
417                text_signature: None,
418                html_signature: None,
419            },
420        );
421
422        let request = IdentitySetRequest {
423            account_id: "acc1".to_string(),
424            if_in_state: None,
425            create: Some(create_map),
426            update: None,
427            destroy: None,
428        };
429
430        let response = identity_set(request, store.as_ref()).await.unwrap();
431        assert!(response.not_created.is_some());
432    }
433
434    #[tokio::test]
435    async fn test_identity_get_not_found() {
436        let store = create_test_store();
437        let request = IdentityGetRequest {
438            account_id: "acc1".to_string(),
439            ids: Some(vec!["nonexistent".to_string()]),
440            properties: None,
441        };
442
443        let response = identity_get(request, store.as_ref()).await.unwrap();
444        assert_eq!(response.not_found.len(), 1);
445    }
446
447    #[tokio::test]
448    async fn test_identity_get_all() {
449        let store = create_test_store();
450        let request = IdentityGetRequest {
451            account_id: "acc1".to_string(),
452            ids: None,
453            properties: None,
454        };
455
456        let response = identity_get(request, store.as_ref()).await.unwrap();
457        assert_eq!(response.list.len(), 1);
458    }
459
460    #[tokio::test]
461    async fn test_identity_set_update() {
462        let store = create_test_store();
463        let mut update_map = HashMap::new();
464        update_map.insert(
465            "default".to_string(),
466            serde_json::json!({"name": "New Name"}),
467        );
468
469        let request = IdentitySetRequest {
470            account_id: "acc1".to_string(),
471            if_in_state: None,
472            create: None,
473            update: Some(update_map),
474            destroy: None,
475        };
476
477        let response = identity_set(request, store.as_ref()).await.unwrap();
478        assert!(response.not_updated.is_some());
479    }
480
481    #[tokio::test]
482    async fn test_identity_changes_state_progression() {
483        let store = create_test_store();
484
485        let request1 = IdentityChangesRequest {
486            account_id: "acc1".to_string(),
487            since_state: "5".to_string(),
488            max_changes: None,
489        };
490        let response1 = identity_changes(request1, store.as_ref()).await.unwrap();
491
492        let request2 = IdentityChangesRequest {
493            account_id: "acc1".to_string(),
494            since_state: response1.new_state.clone(),
495            max_changes: None,
496        };
497        let response2 = identity_changes(request2, store.as_ref()).await.unwrap();
498
499        assert!(response1.new_state < response2.new_state);
500    }
501
502    #[tokio::test]
503    async fn test_identity_default_may_not_delete() {
504        let store = create_test_store();
505        let request = IdentityGetRequest {
506            account_id: "acc1".to_string(),
507            ids: Some(vec!["default".to_string()]),
508            properties: None,
509        };
510
511        let response = identity_get(request, store.as_ref()).await.unwrap();
512        assert!(!response.list[0].may_delete);
513    }
514
515    #[tokio::test]
516    async fn test_identity_with_reply_to() {
517        let store = create_test_store();
518        let mut create_map = HashMap::new();
519        let reply_to = vec![crate::types::EmailAddress::new(
520            "support@example.com".to_string(),
521        )];
522
523        create_map.insert(
524            "replyto1".to_string(),
525            IdentityObject {
526                name: "Support".to_string(),
527                email: "noreply@example.com".to_string(),
528                reply_to: Some(reply_to),
529                bcc: None,
530                text_signature: None,
531                html_signature: None,
532            },
533        );
534
535        let request = IdentitySetRequest {
536            account_id: "acc1".to_string(),
537            if_in_state: None,
538            create: Some(create_map),
539            update: None,
540            destroy: None,
541        };
542
543        let response = identity_set(request, store.as_ref()).await.unwrap();
544        assert!(response.not_created.is_some());
545    }
546}