Skip to content

Commit aeba690

Browse files
committed
Merge branch 'main' into ai-deep-search
2 parents d73c98c + c7fb77b commit aeba690

File tree

23 files changed

+2591
-1432
lines changed

23 files changed

+2591
-1432
lines changed

bin/scalingo_pgdump.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ pg_dump --clean --if-exists --format c --dbname "${SCALINGO_POSTGRESQL_URL}" --n
2828
export AWS_ACCESS_KEY_ID=${BACKUP_PGSQL_S3_KEY}
2929
export AWS_SECRET_ACCESS_KEY=${BACKUP_PGSQL_S3_SECRET}
3030
export RESTIC_PASSWORD=${BACKUP_PGSQL_ENCRYPTION_PASS}
31-
export RESTIC_REPOSITORY=s3:https://s3.fr-par.scw.cloud/${BACKUP_PGSQL_S3_BUCKET}/${APP}
31+
export RESTIC_REPOSITORY=s3:${BACKUP_PGSQL_S3_REPOSITORY}/${APP}
3232

3333
./restic snapshots -q || ./restic init
3434

src/backend/core/admin.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,6 @@ def import_imap_view(self, request):
369369
recipient=form.cleaned_data["recipient"],
370370
user=request.user,
371371
use_ssl=form.cleaned_data["use_ssl"],
372-
folder=form.cleaned_data["folder"],
373-
max_messages=form.cleaned_data["max_messages"],
374372
request=request,
375373
)
376374
if success:

src/backend/core/api/openapi.json

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1424,7 +1424,7 @@
14241424
"/api/v1.0/import/imap/": {
14251425
"post": {
14261426
"operationId": "import_imap_create",
1427-
"description": "\n Import messages from an IMAP server.\n \n This endpoint initiates an asynchronous import process from an IMAP server.\n The import is processed in the background and returns a task ID for tracking.\n \n Required parameters:\n - imap_server: Hostname of the IMAP server\n - imap_port: Port number for the IMAP server\n - username: IMAP account username\n - password: IMAP account password\n - recipient: ID of the mailbox to import messages into\n \n Optional parameters:\n - use_ssl: Whether to use SSL for the connection (default: true)\n - folder: IMAP folder to import from (default: \"INBOX\")\n - max_messages: Maximum number of messages to import (default: 0, meaning all messages)\n ",
1427+
"description": "\n Import messages from an IMAP server.\n \n This endpoint initiates an asynchronous import process from an IMAP server.\n The import is processed in the background and returns a task ID for tracking.\n \n Required parameters:\n - imap_server: Hostname of the IMAP server\n - imap_port: Port number for the IMAP server\n - username: IMAP account username\n - password: IMAP account password\n - recipient: ID of the mailbox to import messages into\n \n Optional parameters:\n - use_ssl: Whether to use SSL for the connection (default: true)\n ",
14281428
"tags": [
14291429
"import"
14301430
],
@@ -4067,18 +4067,6 @@
40674067
"type": "boolean",
40684068
"default": true,
40694069
"description": "Use SSL for IMAP connection"
4070-
},
4071-
"folder": {
4072-
"type": "string",
4073-
"minLength": 1,
4074-
"default": "INBOX",
4075-
"description": "IMAP folder to import from"
4076-
},
4077-
"max_messages": {
4078-
"type": "integer",
4079-
"minimum": 0,
4080-
"default": 0,
4081-
"description": "Maximum number of messages to import (0 for all)"
40824070
}
40834071
},
40844072
"required": [

src/backend/core/api/serializers.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -802,12 +802,3 @@ class ImportIMAPSerializer(ImportBaseSerializer):
802802
use_ssl = serializers.BooleanField(
803803
help_text="Use SSL for IMAP connection", required=False, default=True
804804
)
805-
folder = serializers.CharField(
806-
help_text="IMAP folder to import from", required=False, default="INBOX"
807-
)
808-
max_messages = serializers.IntegerField(
809-
help_text="Maximum number of messages to import (0 for all)",
810-
required=False,
811-
default=0,
812-
min_value=0,
813-
)

src/backend/core/api/viewsets/import_message.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,6 @@ def import_file(self, request):
129129
130130
Optional parameters:
131131
- use_ssl: Whether to use SSL for the connection (default: true)
132-
- folder: IMAP folder to import from (default: "INBOX")
133-
- max_messages: Maximum number of messages to import (default: 0, meaning all messages)
134132
""",
135133
)
136134
@action(detail=False, methods=["post"], url_path="imap")
@@ -149,8 +147,6 @@ def import_imap(self, request):
149147
recipient=mailbox,
150148
user=request.user,
151149
use_ssl=data.get("use_ssl", True),
152-
folder=data.get("folder", "INBOX"),
153-
max_messages=data.get("max_messages", 0),
154150
)
155151

156152
if not success:

src/backend/core/forms.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,3 @@ class IMAPImportForm(forms.Form):
7474
required=False,
7575
initial=True,
7676
)
77-
folder = forms.CharField(
78-
label="Folder",
79-
help_text="IMAP folder to import from (e.g. INBOX)",
80-
required=True,
81-
initial="INBOX",
82-
)
83-
max_messages = forms.IntegerField(
84-
label="Maximum Messages",
85-
help_text="Maximum number of messages to import (0 for all)",
86-
required=False,
87-
initial=0,
88-
min_value=0,
89-
)

src/backend/core/mda/inbound.py

Lines changed: 139 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -20,36 +20,54 @@
2020
# Helper function to extract Message-IDs
2121
MESSAGE_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

@@ -59,33 +77,50 @@
5977

6078
def 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+
233322
def 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

Comments
 (0)