Skip to main content

caldir_core/event/
attachment.rs

1use icalendar::Property;
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub struct Attachment {
5    pub uri: String,
6    pub params: Vec<(String, String)>,
7}
8
9impl Attachment {
10    pub fn new(uri: impl Into<String>) -> Self {
11        Attachment {
12            uri: uri.into(),
13            params: Vec::new(),
14        }
15    }
16
17    /// The human-readable `FILENAME` parameter, if present. CalDAV managed
18    /// attachments and Google Drive attachments both carry the file's name
19    /// here; bare `ATTACH:<uri>` references don't.
20    pub fn filename(&self) -> Option<&str> {
21        self.params
22            .iter()
23            .find(|(k, _)| k.eq_ignore_ascii_case("FILENAME"))
24            .map(|(_, v)| v.as_str())
25    }
26
27    /// Parse an `ATTACH` property, keeping it ONLY when it's a URI reference (default behaviour
28    /// for Google Drive attachments and iCloud attachments)
29    /// Returns `None` for inline binary attachments (we don't want to store long blobs)
30    pub fn from_property(prop: &Property) -> Option<Self> {
31        if is_binary(prop) {
32            return None;
33        }
34
35        let params = prop
36            .params()
37            .values()
38            .map(|p| (p.key().to_string(), p.value().to_string()))
39            .collect();
40
41        Some(Attachment {
42            uri: prop.value().to_string(),
43            params,
44        })
45    }
46}
47
48fn is_binary(prop: &Property) -> bool {
49    prop.params().values().any(|p| {
50        (p.key().eq_ignore_ascii_case("VALUE") && p.value().eq_ignore_ascii_case("BINARY"))
51            || (p.key().eq_ignore_ascii_case("ENCODING")
52                && p.value().eq_ignore_ascii_case("BASE64"))
53    })
54}
55
56impl From<&Attachment> for Property {
57    fn from(value: &Attachment) -> Self {
58        let mut prop = Property::new("ATTACH", &value.uri);
59        for (k, v) in &value.params {
60            prop.add_parameter(k, v);
61        }
62        prop.done()
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn keeps_uri_value() {
72        let prop = Property::new("ATTACH", "https://drive.google.com/file/d/abc").done();
73
74        let attachment = Attachment::from_property(&prop).unwrap();
75
76        assert_eq!(attachment.uri, "https://drive.google.com/file/d/abc");
77        assert!(attachment.params.is_empty());
78    }
79
80    #[test]
81    fn captures_params() {
82        // Apple / CalDAV managed attachment: MANAGED-ID must survive so the
83        // server keeps the attachment on a later PUT (RFC 8607).
84        let mut prop = Property::new("ATTACH", "https://p01.icloud.com/att/abc");
85        prop.add_parameter("MANAGED-ID", "1");
86        prop.add_parameter("FMTTYPE", "application/pdf");
87        prop.add_parameter("FILENAME", "agenda.pdf");
88
89        let attachment = Attachment::from_property(&prop.done()).unwrap();
90
91        assert!(
92            attachment
93                .params
94                .contains(&("MANAGED-ID".to_string(), "1".to_string()))
95        );
96        assert!(
97            attachment
98                .params
99                .contains(&("FILENAME".to_string(), "agenda.pdf".to_string()))
100        );
101    }
102
103    #[test]
104    fn filename_reads_param_case_insensitively() {
105        let mut prop = Property::new("ATTACH", "https://p01.icloud.com/att/abc");
106        prop.add_parameter("filename", "agenda.pdf");
107
108        let attachment = Attachment::from_property(&prop.done()).unwrap();
109
110        assert_eq!(attachment.filename(), Some("agenda.pdf"));
111    }
112
113    #[test]
114    fn filename_is_none_without_param() {
115        let attachment = Attachment::new("https://drive.google.com/file/d/abc");
116
117        assert_eq!(attachment.filename(), None);
118    }
119
120    #[test]
121    fn skips_binary_value() {
122        let mut prop = Property::new("ATTACH", "VGhlIHF1aWNr");
123        prop.add_parameter("VALUE", "BINARY");
124        prop.add_parameter("ENCODING", "BASE64");
125
126        assert!(Attachment::from_property(&prop.done()).is_none());
127    }
128
129    #[test]
130    fn skips_base64_encoding_without_value_param() {
131        let mut prop = Property::new("ATTACH", "VGhlIHF1aWNr");
132        prop.add_parameter("ENCODING", "BASE64");
133
134        assert!(Attachment::from_property(&prop.done()).is_none());
135    }
136
137    #[test]
138    fn writes_attach_property_with_params() {
139        let attachment = Attachment {
140            uri: "https://example.com/report.doc".to_string(),
141            params: vec![("FMTTYPE".to_string(), "application/msword".to_string())],
142        };
143
144        let prop = Property::from(&attachment);
145
146        assert_eq!(prop.key(), "ATTACH");
147        assert_eq!(prop.value(), "https://example.com/report.doc");
148        assert_eq!(
149            prop.params().get("FMTTYPE").map(|p| p.value()),
150            Some("application/msword")
151        );
152    }
153}