2020# Helper function to extract Message-IDs
2121MESSAGE_ID_RE = re .compile (r"<([^<>]+)>" )
2222
23-
24- GMAIL_LABEL_TO_MESSAGE_FLAG = {
23+ IMAP_LABEL_TO_MESSAGE_FLAG = {
2524 "Drafts" : "is_draft" ,
2625 "Brouillons" : "is_draft" ,
26+ "[Gmail]/Drafts" : "is_draft" ,
27+ "[Gmail]/Brouillons" : "is_draft" ,
28+ "DRAFT" : "is_draft" ,
2729 "Sent" : "is_sender" ,
2830 "Messages envoyés" : "is_sender" ,
31+ "[Gmail]/Sent Mail" : "is_sender" ,
32+ "[Gmail]/Mails envoyés" : "is_sender" ,
33+ "[Gmail]/Messages envoyés" : "is_sender" ,
34+ "Sent Mail" : "is_sender" ,
35+ "Mails envoyés" : "is_sender" ,
2936 "Archived" : "is_archived" ,
3037 "Messages archivés" : "is_archived" ,
3138 "Starred" : "is_starred" ,
39+ "[Gmail]/Starred" : "is_starred" ,
40+ "[Gmail]/Suivis" : "is_starred" ,
3241 "Favoris" : "is_starred" ,
3342 "Trash" : "is_trashed" ,
43+ "TRASH" : "is_trashed" ,
44+ "[Gmail]/Corbeille" : "is_trashed" ,
3445 "Corbeille" : "is_trashed" ,
46+ # TODO: '[Gmail]/Important'
47+ "OUTBOX" : "is_sender" ,
3548}
3649
37- GMAIL_LABEL_TO_THREAD_FLAG = {
50+ IMAP_LABEL_TO_THREAD_FLAG = {
3851 "Spam" : "is_spam" ,
52+ "QUARANTAINE" : "is_spam" ,
3953}
4054
41- GMAIL_READ_UNREAD_LABELS = {
55+ IMAP_READ_UNREAD_LABELS = {
4256 "Ouvert" : "read" ,
4357 "Non lus" : "unread" ,
4458 "Opened" : "read" ,
4559 "Unread" : "unread" ,
4660}
4761
48- GMAIL_LABELS_TO_IGNORE = [
62+ IMAP_LABELS_TO_IGNORE = [
4963 "Promotions" ,
5064 "Social" ,
5165 "Boîte de réception" ,
5266 "Inbox" ,
67+ "INBOX" ,
68+ "[Gmail]/Important" ,
69+ "[Gmail]/All Mail" ,
70+ "[Gmail]/Tous les messages" ,
5371]
5472
5573
5977
6078def compute_labels_and_flags (
6179 parsed_email : Dict [str , Any ],
80+ imap_labels : Optional [List [str ]],
81+ imap_flags : Optional [List [str ]],
6282) -> Tuple [List [str ], Dict [str , bool ], Dict [str , bool ]]:
6383 """Compute labels and flags for a parsed email."""
64- labels = parsed_email .get ("gmail_labels" , [])
84+
85+ # Combine both imap_labels and gmail_labels from parsed email
86+ gmail_labels = parsed_email .get ("gmail_labels" , [])
87+ imap_labels = imap_labels or []
88+ imap_flags = imap_flags or []
89+ all_labels = list (imap_labels ) + list (gmail_labels )
90+
6591 message_flags = {}
6692 thread_flags = {}
6793 labels_to_add = []
68- for label in labels :
94+ for label in all_labels :
6995 # Handle read/unread status
70- if label in GMAIL_READ_UNREAD_LABELS :
71- if GMAIL_READ_UNREAD_LABELS [label ] == "read" :
96+ if label in IMAP_READ_UNREAD_LABELS :
97+ if IMAP_READ_UNREAD_LABELS [label ] == "read" :
7298 message_flags ["is_unread" ] = False
73- elif GMAIL_READ_UNREAD_LABELS [label ] == "unread" :
99+ elif IMAP_READ_UNREAD_LABELS [label ] == "unread" :
74100 message_flags ["is_unread" ] = True
75101 continue # Skip further processing for this label
76- message_flag = GMAIL_LABEL_TO_MESSAGE_FLAG .get (label )
77- thread_flag = GMAIL_LABEL_TO_THREAD_FLAG .get (label )
102+ message_flag = IMAP_LABEL_TO_MESSAGE_FLAG .get (label )
103+ thread_flag = IMAP_LABEL_TO_THREAD_FLAG .get (label )
78104 if message_flag :
79105 message_flags [message_flag ] = True
80106 elif thread_flag :
81107 thread_flags [thread_flag ] = True
82- elif label not in GMAIL_LABELS_TO_IGNORE :
108+ elif label not in IMAP_LABELS_TO_IGNORE :
83109 labels_to_add .append (label )
84110
111+ # Handle read/unread status via IMAP flags
112+ if imap_flags :
113+ # If the \\Seen flag is present, the message is read
114+ is_seen = "\\ Seen" in imap_flags
115+ message_flags ["is_unread" ] = not is_seen
116+
85117 # Special case: if message is sender or draft, it should not be unread
86118 if message_flags .get ("is_sender" ) or message_flags .get ("is_draft" ):
87119 message_flags ["is_unread" ] = False
88120
121+ if "is_sender" in imap_flags :
122+ message_flags ["is_sender" ] = True
123+
89124 return labels_to_add , message_flags , thread_flags
90125
91126
@@ -230,11 +265,67 @@ def canonicalize_subject(subject):
230265 return None # potential_parents.first().thread
231266
232267
268+ def _find_thread_by_message_ids (
269+ in_reply_to : str , references : str , mailbox : models .Mailbox
270+ ) -> Optional [models .Thread ]:
271+ """Find thread by message IDs (in_reply_to and references)."""
272+ # First try to find a thread by message IDs
273+ if in_reply_to or references :
274+ thread = models .Thread .objects .filter (
275+ messages__mime_id__in = [in_reply_to ] if in_reply_to else [],
276+ accesses__mailbox = mailbox ,
277+ ).first ()
278+ if not thread and references :
279+ # Extract message IDs from references
280+ ref_ids = MESSAGE_ID_RE .findall (references )
281+ if ref_ids :
282+ thread = models .Thread .objects .filter (
283+ messages__mime_id__in = ref_ids ,
284+ accesses__mailbox = mailbox ,
285+ ).first ()
286+ return thread
287+ return None
288+
289+
290+ def _handle_duplicate_message (
291+ existing_message : models .Message ,
292+ parsed_email : Dict [str , Any ],
293+ imap_labels : List [str ],
294+ imap_flags : List [str ],
295+ mailbox : models .Mailbox ,
296+ ) -> None :
297+ """Handle duplicate message by updating labels and flags."""
298+ # get labels from parsed_email
299+ labels , message_flags , thread_flags = compute_labels_and_flags (
300+ parsed_email , imap_labels , imap_flags
301+ )
302+ for label in labels :
303+ try :
304+ label_obj , _ = models .Label .objects .get_or_create (
305+ name = label , mailbox = mailbox
306+ )
307+ existing_message .thread .labels .add (label_obj )
308+ for flag , value in message_flags .items ():
309+ if hasattr (existing_message , flag ):
310+ setattr (existing_message , flag , value )
311+ existing_message .save (update_fields = [flag ])
312+ existing_message .save (update_fields = message_flags .keys ())
313+ for flag , value in thread_flags .items ():
314+ if hasattr (existing_message .thread , flag ):
315+ setattr (existing_message .thread , flag , value )
316+ existing_message .thread .save (update_fields = thread_flags .keys ())
317+ except Exception as e :
318+ logger .exception ("Error creating label %s: %s" , label , e )
319+ continue
320+
321+
233322def deliver_inbound_message ( # pylint: disable=too-many-branches, too-many-statements, too-many-locals
234323 recipient_email : str ,
235324 parsed_email : Dict [str , Any ],
236325 raw_data : bytes ,
237326 is_import : bool = False ,
327+ imap_labels : Optional [List [str ]] = None ,
328+ imap_flags : Optional [List [str ]] = None ,
238329) -> bool : # Return True on success, False on failure
239330 """Deliver a parsed inbound email message to the correct mailbox and thread.
240331
@@ -267,6 +358,10 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat
267358 ).first ()
268359
269360 if existing_message :
361+ if is_import and imap_labels :
362+ _handle_duplicate_message (
363+ existing_message , parsed_email , imap_labels , imap_flags , mailbox
364+ )
270365 logger .info (
271366 "Skipping duplicate message %s (MIME ID: %s) in mailbox %s" ,
272367 existing_message .id ,
@@ -277,46 +372,34 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat
277372
278373 # --- 3. Find or Create Thread --- #
279374 try :
280- # thread = None
281- # if is_import:
282- # # During import, try to find an existing thread that contains messages
283- # # with the same subject or referenced message IDs
284- # subject = parsed_email.get("subject", "")
285- # in_reply_to = parsed_email.get("in_reply_to")
286- # references = parsed_email.get("headers", {}).get("references", "")
287-
288- # # First try to find a thread by message IDs
289- # if in_reply_to or references:
290- # thread = models.Thread.objects.filter(
291- # messages__mime_id__in=[in_reply_to] if in_reply_to else [],
292- # accesses__mailbox=mailbox,
293- # ).first()
294- # if not thread and references:
295- # # Extract message IDs from references
296- # ref_ids = MESSAGE_ID_RE.findall(references)
297- # if ref_ids:
298- # thread = models.Thread.objects.filter(
299- # messages__mime_id__in=ref_ids,
300- # accesses__mailbox=mailbox,
301- # ).first()
302-
303- # # If no thread found by message IDs, try by subject
304- # if not thread and subject:
305- # # Look for threads with similar subjects
306- # canonical_subject = re.sub(
307- # r"^((re|fwd|fw|rep|tr|rép)\s*:\s+)+",
308- # "",
309- # subject.lower(),
310- # flags=re.IGNORECASE,
311- # ).strip()
312- # thread = models.Thread.objects.filter(
313- # subject__iregex=rf"^(re|fwd|fw|rep|tr|rép)\s*:\s*{re.escape(canonical_subject)}$",
314- # accesses__mailbox=mailbox,
315- # ).first()
316-
317- # # If no thread found or not an import, use normal thread finding logic
318- # if not thread:
319- thread = find_thread_for_inbound_message (parsed_email , mailbox )
375+ thread = None
376+ if is_import :
377+ # During import, try to find an existing thread that contains messages
378+ # with the same subject or referenced message IDs
379+ subject = parsed_email .get ("subject" , "" )
380+ in_reply_to = parsed_email .get ("in_reply_to" )
381+ references = parsed_email .get ("headers" , {}).get ("references" , "" )
382+
383+ # First try to find a thread by message IDs
384+ thread = _find_thread_by_message_ids (in_reply_to , references , mailbox )
385+
386+ # If no thread found by message IDs, try by subject
387+ if not thread and subject :
388+ # Look for threads with similar subjects
389+ canonical_subject = re .sub (
390+ r"^((re|fwd|fw|rep|tr|rép)\s*:\s+)+" ,
391+ "" ,
392+ subject .lower (),
393+ flags = re .IGNORECASE ,
394+ ).strip ()
395+ thread = models .Thread .objects .filter (
396+ subject__iregex = rf"^(re|fwd|fw|rep|tr|rép)\s*:\s*{ re .escape (canonical_subject )} $" ,
397+ accesses__mailbox = mailbox ,
398+ ).first ()
399+
400+ # If no thread found or not an import, use normal thread finding logic
401+ if not thread :
402+ thread = find_thread_for_inbound_message (parsed_email , mailbox )
320403
321404 if not thread :
322405 snippet = ""
@@ -355,7 +438,9 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat
355438
356439 if is_import :
357440 # get labels from parsed_email
358- labels , message_flags , thread_flags = compute_labels_and_flags (parsed_email )
441+ labels , message_flags , thread_flags = compute_labels_and_flags (
442+ parsed_email , imap_labels , imap_flags
443+ )
359444 for label in labels :
360445 try :
361446 label_obj , _ = models .Label .objects .get_or_create (
@@ -367,7 +452,6 @@ def deliver_inbound_message( # pylint: disable=too-many-branches, too-many-stat
367452 continue
368453
369454 # --- 4. Get or Create Sender Contact --- #
370- logger .warning (parsed_email )
371455 sender_info = parsed_email .get ("from" , {})
372456 sender_email = sender_info .get ("email" )
373457 sender_name = sender_info .get ("name" )
0 commit comments