Skip to main content

imessage_serializers/
message.rs

1/// Message serializer — converts a Message entity to the API JSON shape.
2///
3/// This is the most complex serializer. The field order and conditional
4/// inclusion logic must match the API contract exactly.
5///
6/// The response is built in this specific order:
7/// 1. Core fields (always present)
8/// 2. Non-notification fields (conditionally added)
9/// 3. Monterey+ fields (wasDeliveredQuietly, didNotifyRecipient)
10/// 4. Chats (if config.includeChats)
11/// 5. High Sierra+ fields (messageSummaryInfo, payloadData)
12/// 6. Ventura+ fields (dateEdited, dateRetracted, partCount)
13use serde_json::{Map, Value, json};
14
15use imessage_core::typedstream;
16use imessage_db::imessage::entities::Message;
17
18use crate::attachment::serialize_attachments;
19use crate::chat::serialize_chats;
20use crate::config::{AttachmentSerializerConfig, ChatSerializerConfig, MessageSerializerConfig};
21use crate::handle::serialize_handle;
22use crate::plist_decode;
23
24/// Serialize a single Message to JSON.
25pub fn serialize_message(
26    message: &Message,
27    config: &MessageSerializerConfig,
28    attachment_config: &AttachmentSerializerConfig,
29    is_for_notification: bool,
30) -> Value {
31    let mut map = Map::new();
32
33    // --- Core fields (always present) ---
34    map.insert("originalROWID".to_string(), json!(message.rowid));
35    map.insert("guid".to_string(), json!(message.guid));
36
37    // text: universalText logic — use text field, falling back to attributedBody text
38    // On Tahoe (macOS 26+), outgoing self-messages may have null text column;
39    // the actual text is only in the attributedBody typedstream blob.
40    let text = match &message.text {
41        Some(t) if !t.is_empty() => Some(t.clone()),
42        _ => message
43            .attributed_body
44            .as_deref()
45            .and_then(typedstream::extract_text),
46    };
47    map.insert("text".to_string(), json!(text));
48
49    // attributedBody: decode typedstream blob to JSON
50    let attributed_body = message
51        .attributed_body
52        .as_deref()
53        .and_then(typedstream::decode_attributed_body);
54    map.insert(
55        "attributedBody".to_string(),
56        attributed_body.unwrap_or(Value::Null),
57    );
58
59    // handle
60    let handle_json = message.handle.as_ref().map(|h| serialize_handle(h, false));
61    map.insert("handle".to_string(), handle_json.unwrap_or(Value::Null));
62
63    map.insert("handleId".to_string(), json!(message.handle_id));
64    map.insert("otherHandle".to_string(), json!(message.other_handle));
65
66    // attachments
67    map.insert(
68        "attachments".to_string(),
69        serialize_attachments(&message.attachments, attachment_config, is_for_notification),
70    );
71
72    map.insert("subject".to_string(), json!(message.subject));
73    map.insert("error".to_string(), json!(message.error));
74    map.insert("dateCreated".to_string(), json!(message.date));
75    map.insert("dateRead".to_string(), json!(message.date_read));
76    map.insert("dateDelivered".to_string(), json!(message.date_delivered));
77    map.insert("isDelivered".to_string(), json!(message.is_delivered));
78    map.insert("isFromMe".to_string(), json!(message.is_from_me));
79    map.insert("hasDdResults".to_string(), json!(message.has_dd_results));
80    map.insert("isArchived".to_string(), json!(message.is_archive));
81    map.insert("itemType".to_string(), json!(message.item_type));
82    map.insert("groupTitle".to_string(), json!(message.group_title));
83    map.insert(
84        "groupActionType".to_string(),
85        json!(message.group_action_type),
86    );
87    map.insert(
88        "balloonBundleId".to_string(),
89        json!(message.balloon_bundle_id),
90    );
91    map.insert(
92        "associatedMessageGuid".to_string(),
93        json!(message.associated_message_guid),
94    );
95    map.insert(
96        "associatedMessageType".to_string(),
97        json!(message.associated_message_type),
98    );
99    map.insert(
100        "associatedMessageEmoji".to_string(),
101        json!(message.associated_message_emoji),
102    );
103    map.insert(
104        "expressiveSendStyleId".to_string(),
105        json!(message.expressive_send_style_id),
106    );
107    map.insert(
108        "threadOriginatorGuid".to_string(),
109        json!(message.thread_originator_guid),
110    );
111
112    // hasPayloadData: boolean indicating if payload_data blob exists
113    let has_payload = message.payload_data.is_some();
114    map.insert("hasPayloadData".to_string(), json!(has_payload));
115
116    // --- Non-notification fields ---
117    if !is_for_notification {
118        map.insert("country".to_string(), json!(message.country));
119        map.insert("isDelayed".to_string(), json!(message.is_delayed));
120        map.insert("isAutoReply".to_string(), json!(message.is_auto_reply));
121        map.insert(
122            "isSystemMessage".to_string(),
123            json!(message.is_system_message),
124        );
125        map.insert(
126            "isServiceMessage".to_string(),
127            json!(message.is_service_message),
128        );
129        map.insert("isForward".to_string(), json!(message.is_forward));
130        map.insert(
131            "threadOriginatorPart".to_string(),
132            json!(message.thread_originator_part),
133        );
134        map.insert(
135            "isCorrupt".to_string(),
136            json!(message.is_corrupt.unwrap_or(false)),
137        );
138        map.insert("datePlayed".to_string(), json!(message.date_played));
139        map.insert("cacheRoomnames".to_string(), json!(message.cache_roomnames));
140        map.insert(
141            "isSpam".to_string(),
142            json!(message.is_spam.unwrap_or(false)),
143        );
144
145        // Note: isExpired maps to isExpirable (the entity field name)
146        map.insert("isExpired".to_string(), json!(message.is_expirable));
147
148        map.insert(
149            "timeExpressiveSendPlayed".to_string(),
150            json!(message.time_expressive_send_played),
151        );
152        map.insert(
153            "isAudioMessage".to_string(),
154            json!(message.is_audio_message),
155        );
156        map.insert("replyToGuid".to_string(), json!(message.reply_to_guid));
157        map.insert("shareStatus".to_string(), json!(message.share_status));
158        map.insert("shareDirection".to_string(), json!(message.share_direction));
159
160        map.insert(
161            "wasDeliveredQuietly".to_string(),
162            json!(message.was_delivered_quietly.unwrap_or(false)),
163        );
164        map.insert(
165            "didNotifyRecipient".to_string(),
166            json!(message.did_notify_recipient.unwrap_or(false)),
167        );
168    }
169
170    // --- Chats (if config.includeChats) ---
171    if config.include_chats {
172        let chat_config = ChatSerializerConfig {
173            include_participants: false,
174            include_messages: false,
175        };
176        map.insert(
177            "chats".to_string(),
178            serialize_chats(&message.chats, &chat_config, is_for_notification),
179        );
180    }
181
182    // messageSummaryInfo: binary plist blob with short key renaming
183    let msg_summary = message
184        .message_summary_info
185        .as_deref()
186        .and_then(plist_decode::decode_message_plist);
187    map.insert(
188        "messageSummaryInfo".to_string(),
189        msg_summary.unwrap_or(Value::Null),
190    );
191
192    // payloadData: binary plist blob with short key renaming
193    let payload = message
194        .payload_data
195        .as_deref()
196        .and_then(plist_decode::decode_message_plist);
197    map.insert("payloadData".to_string(), payload.unwrap_or(Value::Null));
198
199    map.insert("dateEdited".to_string(), json!(message.date_edited));
200    map.insert("dateRetracted".to_string(), json!(message.date_retracted));
201    map.insert("partCount".to_string(), json!(message.part_count));
202
203    // --- Post-processing: null out blobs if config says not to parse ---
204    if !config.parse_attributed_body
205        && let Some(v) = map.get_mut("attributedBody")
206    {
207        *v = Value::Null;
208    }
209    if !config.parse_message_summary
210        && let Some(v) = map.get_mut("messageSummaryInfo")
211    {
212        *v = Value::Null;
213    }
214    if !config.parse_payload_data
215        && let Some(v) = map.get_mut("payloadData")
216    {
217        *v = Value::Null;
218    }
219
220    Value::Object(map)
221}
222
223/// Serialize a list of Messages to JSON.
224pub fn serialize_messages(
225    messages: &[Message],
226    config: &MessageSerializerConfig,
227    attachment_config: &AttachmentSerializerConfig,
228    is_for_notification: bool,
229) -> Value {
230    let list: Vec<Value> = messages
231        .iter()
232        .map(|m| serialize_message(m, config, attachment_config, is_for_notification))
233        .collect();
234    Value::Array(list)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use imessage_db::imessage::entities::Handle;
241
242    fn test_message() -> Message {
243        Message {
244            rowid: 100,
245            guid: "MSG-GUID-1234".to_string(),
246            text: Some("Hello, world!".to_string()),
247            handle_id: 1,
248            handle: Some(Handle {
249                rowid: 1,
250                id: "+15551234567".to_string(),
251                country: Some("us".to_string()),
252                service: "iMessage".to_string(),
253                uncanonicalized_id: None,
254            }),
255            date: Some(1700000000000),
256            date_read: None,
257            date_delivered: Some(1700000001000),
258            is_from_me: false,
259            is_delivered: true,
260            is_archive: false,
261            item_type: 0,
262            group_action_type: 0,
263            has_dd_results: false,
264            error: 0,
265            ..Default::default()
266        }
267    }
268
269    #[test]
270    fn serialize_basic_message() {
271        let config = MessageSerializerConfig {
272            include_chats: false,
273            ..Default::default()
274        };
275        let att_config = AttachmentSerializerConfig::default();
276        let json = serialize_message(&test_message(), &config, &att_config, false);
277
278        assert_eq!(json["originalROWID"], 100);
279        assert_eq!(json["guid"], "MSG-GUID-1234");
280        assert_eq!(json["text"], "Hello, world!");
281        assert_eq!(json["handleId"], 1);
282        assert_eq!(json["isFromMe"], false);
283        assert_eq!(json["isDelivered"], true);
284        assert_eq!(json["dateCreated"], 1700000000000i64);
285        assert_eq!(json["dateDelivered"], 1700000001000i64);
286        assert!(json["dateRead"].is_null());
287    }
288
289    #[test]
290    fn handle_serialized_inline() {
291        let config = MessageSerializerConfig::default();
292        let att_config = AttachmentSerializerConfig::default();
293        let json = serialize_message(&test_message(), &config, &att_config, false);
294
295        assert!(json["handle"].is_object());
296        assert_eq!(json["handle"]["address"], "+15551234567");
297        assert_eq!(json["handle"]["service"], "iMessage");
298    }
299
300    #[test]
301    fn null_handle_when_missing() {
302        let mut msg = test_message();
303        msg.handle = None;
304        let config = MessageSerializerConfig::default();
305        let att_config = AttachmentSerializerConfig::default();
306        let json = serialize_message(&msg, &config, &att_config, false);
307        assert!(json["handle"].is_null());
308    }
309
310    #[test]
311    fn notification_excludes_non_essential_fields() {
312        let config = MessageSerializerConfig::default();
313        let att_config = AttachmentSerializerConfig::default();
314        let json = serialize_message(&test_message(), &config, &att_config, true);
315
316        // Core fields present
317        assert!(json.get("originalROWID").is_some());
318        assert!(json.get("guid").is_some());
319        assert!(json.get("text").is_some());
320
321        // Non-essential fields absent
322        assert!(json.get("country").is_none());
323        assert!(json.get("isDelayed").is_none());
324        assert!(json.get("isAutoReply").is_none());
325        assert!(json.get("shareStatus").is_none());
326    }
327
328    #[test]
329    fn is_expired_maps_to_is_expirable() {
330        let mut msg = test_message();
331        msg.is_expirable = true;
332        let config = MessageSerializerConfig::default();
333        let att_config = AttachmentSerializerConfig::default();
334        let json = serialize_message(&msg, &config, &att_config, false);
335        assert_eq!(json["isExpired"], true);
336    }
337
338    #[test]
339    fn has_payload_data_flag() {
340        let mut msg = test_message();
341        msg.payload_data = Some(vec![1, 2, 3]);
342        let config = MessageSerializerConfig::default();
343        let att_config = AttachmentSerializerConfig::default();
344        let json = serialize_message(&msg, &config, &att_config, false);
345        assert_eq!(json["hasPayloadData"], true);
346
347        msg.payload_data = None;
348        let json = serialize_message(&msg, &config, &att_config, false);
349        assert_eq!(json["hasPayloadData"], false);
350    }
351
352    #[test]
353    fn empty_attachments_array() {
354        let config = MessageSerializerConfig::default();
355        let att_config = AttachmentSerializerConfig::default();
356        let json = serialize_message(&test_message(), &config, &att_config, false);
357        assert!(json["attachments"].is_array());
358        assert_eq!(json["attachments"].as_array().unwrap().len(), 0);
359    }
360
361    #[test]
362    fn field_order_core() {
363        let config = MessageSerializerConfig {
364            include_chats: false,
365            ..Default::default()
366        };
367        let att_config = AttachmentSerializerConfig::default();
368        let json = serialize_message(&test_message(), &config, &att_config, true);
369        let serialized = serde_json::to_string(&json).unwrap();
370
371        // Verify critical ordering
372        let rowid_pos = serialized.find("originalROWID").unwrap();
373        let guid_pos = serialized.find("\"guid\"").unwrap();
374        let text_pos = serialized.find("\"text\"").unwrap();
375        let handle_pos = serialized.find("\"handle\"").unwrap();
376        let date_pos = serialized.find("dateCreated").unwrap();
377
378        assert!(rowid_pos < guid_pos);
379        assert!(guid_pos < text_pos);
380        assert!(text_pos < handle_pos);
381        assert!(handle_pos < date_pos);
382    }
383}