django-codenerix-email 4.0.34__py2.py3-none-any.whl → 4.0.36__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. codenerix_email/__init__.py +1 -1
  2. codenerix_email/__pycache__/__init__.cpython-310.pyc +0 -0
  3. codenerix_email/__pycache__/__init__.cpython-311.pyc +0 -0
  4. codenerix_email/__pycache__/models.cpython-310.pyc +0 -0
  5. codenerix_email/__pycache__/models.cpython-311.pyc +0 -0
  6. codenerix_email/management/commands/__pycache__/email_test.cpython-311.pyc +0 -0
  7. codenerix_email/management/commands/__pycache__/emails_recv.cpython-311.pyc +0 -0
  8. codenerix_email/management/commands/__pycache__/emails_send.cpython-311.pyc +0 -0
  9. codenerix_email/management/commands/__pycache__/recv_emails.cpython-311.pyc +0 -0
  10. codenerix_email/management/commands/__pycache__/send_emails.cpython-311.pyc +0 -0
  11. codenerix_email/management/commands/__pycache__/test_email.cpython-311.pyc +0 -0
  12. codenerix_email/management/commands/email_test.py +174 -0
  13. codenerix_email/management/commands/emails_recv.py +601 -0
  14. codenerix_email/management/commands/emails_send.py +238 -0
  15. codenerix_email/management/commands/recv_emails.py +8 -598
  16. codenerix_email/management/commands/send_emails.py +8 -241
  17. codenerix_email/management/commands/test_email.py +8 -171
  18. codenerix_email/models.py +16 -18
  19. {django_codenerix_email-4.0.34.dist-info → django_codenerix_email-4.0.36.dist-info}/METADATA +1 -1
  20. {django_codenerix_email-4.0.34.dist-info → django_codenerix_email-4.0.36.dist-info}/RECORD +23 -16
  21. {django_codenerix_email-4.0.34.dist-info → django_codenerix_email-4.0.36.dist-info}/LICENSE +0 -0
  22. {django_codenerix_email-4.0.34.dist-info → django_codenerix_email-4.0.36.dist-info}/WHEEL +0 -0
  23. {django_codenerix_email-4.0.34.dist-info → django_codenerix_email-4.0.36.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,601 @@
1
+ import re
2
+
3
+ import logging
4
+
5
+ from django.conf import settings
6
+ from django.core.management.base import BaseCommand, CommandError
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from email import message_from_bytes
10
+ from email.header import decode_header
11
+ from email.message import Message
12
+ from email.parser import HeaderParser
13
+ from typing import Optional
14
+
15
+ from codenerix_email.models import (
16
+ EmailMessage,
17
+ EmailReceived,
18
+ BOUNCE_SOFT,
19
+ BOUNCE_HARD,
20
+ )
21
+
22
+
23
+ # Silence DEBUG logs from imapclient
24
+ logging.getLogger("imapclient").setLevel(logging.WARNING)
25
+
26
+ import imaplib # noqa: E402
27
+ from imapclient import IMAPClient # noqa: E402
28
+ from imapclient.exceptions import LoginError # noqa: E402
29
+
30
+
31
+ class Command(BaseCommand):
32
+ help = "Fetches new emails from the configured IMAP account."
33
+
34
+ def add_arguments(self, parser):
35
+ # Named (optional) arguments
36
+ parser.add_argument(
37
+ "--silent",
38
+ action="store_true",
39
+ dest="silent",
40
+ default=False,
41
+ help="Enable silent mode",
42
+ )
43
+
44
+ parser.add_argument(
45
+ "--tracking-id", type=str, help="Tracking ID to filter"
46
+ )
47
+ parser.add_argument("--imap-id", type=str, help="IMAP ID to filter")
48
+ parser.add_argument(
49
+ "--message-id", type=str, help="Message-ID to filter"
50
+ )
51
+ parser.add_argument(
52
+ "--all", action="store_true", help="Process all emails"
53
+ )
54
+ parser.add_argument(
55
+ "--rewrite", action="store_true", help="Rewrite existing"
56
+ )
57
+
58
+ def handle(self, *args, **options):
59
+ # Get configuration
60
+ self.silent = options["silent"]
61
+ self.verbose = not self.silent
62
+ self.imap_id = options.get("imap_id")
63
+ self.message_id = options.get("message_id")
64
+ self.tracking_id = options.get("tracking_id")
65
+ self.rewrite = options.get("rewrite", False)
66
+ self.process_all = options.get("all", False)
67
+
68
+ # Show header
69
+ if self.verbose:
70
+ self.stdout.write(
71
+ self.style.SUCCESS("Starting IMAP email synchronization...")
72
+ )
73
+
74
+ # Get configuration from settings
75
+ host = getattr(settings, "IMAP_EMAIL_HOST", None)
76
+ port = getattr(settings, "IMAP_EMAIL_PORT", 993)
77
+ user = getattr(settings, "IMAP_EMAIL_USER", None)
78
+ password = getattr(settings, "IMAP_EMAIL_PASSWORD", None)
79
+ ssl = getattr(settings, "IMAP_EMAIL_SSL", True)
80
+ folder = getattr(settings, "IMAP_EMAIL_INBOX_FOLDER", "INBOX")
81
+
82
+ # Verify that IMAP settings are configured
83
+ if host is not None and port:
84
+ # Validate configuration
85
+ if user is None or password is None:
86
+ if self.silent:
87
+ return
88
+ else:
89
+ raise CommandError(
90
+ "IMAP user or password not configured. Please set "
91
+ "IMAP_EMAIL_USER and IMAP_EMAIL_PASSWORD in settings."
92
+ )
93
+
94
+ try:
95
+ # Connect to the IMAP server
96
+ server = IMAPClient(host, port=port, ssl=ssl)
97
+ except Exception as e:
98
+ raise CommandError(
99
+ f"Failed to connect to IMAP server ("
100
+ f"{host=}, "
101
+ f"{port=}, "
102
+ f"ssl={ssl and 'yes' or 'no'}"
103
+ f"): {e}"
104
+ ) from e
105
+
106
+ try:
107
+ # Login and select the inbox
108
+ try:
109
+ server.login(user, password)
110
+ except LoginError as e:
111
+ raise CommandError(
112
+ f"Failed to login to IMAP server with {user=}: {e}"
113
+ ) from e
114
+
115
+ if folder:
116
+ try:
117
+ server.select_folder(folder, readonly=False)
118
+ except imaplib.IMAP4.error:
119
+ raise CommandError(f"Failed to select inbox {folder=}")
120
+
121
+ # Process emails
122
+ (created_count, overwritten_count) = self.process(server)
123
+ count = created_count + overwritten_count
124
+
125
+ # Show summary
126
+ if self.verbose:
127
+ self.stdout.write(
128
+ self.style.SUCCESS(
129
+ f"Successfully synchronized {count} emails "
130
+ f"(new: {created_count}, "
131
+ f"overwritten: {overwritten_count})"
132
+ )
133
+ )
134
+
135
+ except Exception as e:
136
+ raise
137
+ self.stderr.write(
138
+ self.style.ERROR(
139
+ f"An error occurred during synchronization: {e}"
140
+ )
141
+ )
142
+
143
+ finally:
144
+ # Logout from the server
145
+ try:
146
+ server.logout()
147
+ except Exception:
148
+ pass
149
+
150
+ elif self.verbose:
151
+ raise CommandError(
152
+ "IMAP settings not configured. Please set IMAP_EMAIL_HOST "
153
+ "and IMAP_EMAIL_PORT in settings."
154
+ )
155
+
156
+ def process(self, server):
157
+ """
158
+ Connects to the IMAP server and fetches new emails,
159
+ saving them as ReceivedEmail objects.
160
+ """
161
+
162
+ # Processed emails count
163
+ created_count = 0
164
+ overwrite_count = 0
165
+
166
+ # Look up for emails
167
+ if self.imap_id:
168
+ # Search by specific IMAP ID
169
+ try:
170
+ imap_id = int(self.imap_id)
171
+ except ValueError:
172
+ raise CommandError(
173
+ f"Invalid IMAP ID '{self.imap_id}'. Must be an integer."
174
+ )
175
+ messages_ids = [imap_id]
176
+ if self.verbose:
177
+ self.stdout.write(
178
+ self.style.SUCCESS(
179
+ f"Processing email with IMAP ID {self.imap_id}."
180
+ )
181
+ )
182
+
183
+ elif self.message_id:
184
+ # Search by specific Message-ID
185
+ messages_ids = server.search(
186
+ ["HEADER", "Message-ID", self.message_id]
187
+ )
188
+ if self.verbose:
189
+ self.stdout.write(
190
+ self.style.SUCCESS(
191
+ f"Found {len(messages_ids)} email(s) with "
192
+ f"Message-ID {self.message_id}."
193
+ )
194
+ )
195
+
196
+ elif self.process_all:
197
+ # Process all emails
198
+ messages_ids = server.search(["ALL"])
199
+ if self.verbose:
200
+ self.stdout.write(
201
+ self.style.SUCCESS(
202
+ f"Found {len(messages_ids)} email(s) to process."
203
+ )
204
+ )
205
+
206
+ else:
207
+ # Search by UNSEEN
208
+ messages_ids = server.search(["UNSEEN"])
209
+ if self.verbose:
210
+ self.stdout.write(
211
+ self.style.SUCCESS(
212
+ f"Found {len(messages_ids)} new email(s) to process."
213
+ )
214
+ )
215
+
216
+ # If there are new messages, fetch and process them
217
+ if messages_ids:
218
+ # Fetch the full message and internal date
219
+ fetched_data = server.fetch(
220
+ messages_ids, ["BODY.PEEK[]", "INTERNALDATE"]
221
+ )
222
+
223
+ # Get the envelope (metadata) and the full body
224
+ # Use IMAP IDs so identifiers do not change between sessions
225
+ for imap_id, message_data in fetched_data.items():
226
+ # Filter out by IMAP ID if specified
227
+ if self.imap_id and str(imap_id) != self.imap_id:
228
+ continue
229
+
230
+ # Get the raw email and internal date
231
+ raw_email = message_data[b"BODY[]"]
232
+ internal_date_naive = message_data[b"INTERNALDATE"]
233
+ internal_date = internal_date_naive.replace(
234
+ tzinfo=ZoneInfo(settings.TIME_ZONE)
235
+ )
236
+
237
+ # Parse the email
238
+ msg = message_from_bytes(raw_email)
239
+
240
+ # Extract subject, efrom, eto & eid
241
+ subject, encoding = decode_header(msg["Subject"])[0]
242
+ if isinstance(subject, bytes):
243
+ subject = subject.decode(encoding or "utf-8")
244
+ efrom = msg.get("From")
245
+ eto = msg.get("To")
246
+ eid = msg.get("Message-ID")
247
+
248
+ # If we can't get a Message-ID, use the IMAP ID as fallback
249
+ # to avoid duplicates
250
+ if not eid:
251
+ eid = f"<imapid-{imap_id}@{settings.IMAP_EMAIL_HOST}>"
252
+
253
+ # Avoid processing duplicates
254
+ email_received = EmailReceived.objects.filter(eid=eid).first()
255
+ if self.rewrite or not email_received:
256
+ # Process multipart emails
257
+ body_plain = ""
258
+ body_html = ""
259
+ if msg.is_multipart():
260
+ for part in msg.walk():
261
+ content_type = part.get_content_type()
262
+ if content_type == "text/plain" and not body_plain:
263
+ body_plain = part.get_payload(
264
+ decode=True
265
+ ).decode(
266
+ part.get_content_charset() or "utf-8",
267
+ "ignore",
268
+ )
269
+ elif content_type == "text/html" and not body_html:
270
+ body_html = part.get_payload(
271
+ decode=True
272
+ ).decode(
273
+ part.get_content_charset() or "utf-8",
274
+ "ignore",
275
+ )
276
+ else:
277
+ body_plain = msg.get_payload(decode=True).decode(
278
+ msg.get_content_charset() or "utf-8",
279
+ "ignore",
280
+ )
281
+
282
+ # Logic to associate replies/bounces with sent emails
283
+ try:
284
+ email_message = None
285
+
286
+ # Locate the tracking ID
287
+ tracking_id = self.find_tracking_id(msg)
288
+
289
+ # Filter out by tracking ID if specified
290
+ if (
291
+ self.tracking_id
292
+ and tracking_id != self.tracking_id
293
+ ):
294
+ continue
295
+
296
+ # If found, try to link to the sent email
297
+ if tracking_id:
298
+ try:
299
+ email_message = EmailMessage.objects.get(
300
+ uuid=tracking_id
301
+ )
302
+ except EmailMessage.DoesNotExist:
303
+ email_message = None
304
+ if self.verbose:
305
+ self.stdout.write(
306
+ self.style.WARNING(
307
+ f"Tracking ID {tracking_id} found "
308
+ "but no matching sent email."
309
+ )
310
+ )
311
+
312
+ except Exception as e:
313
+ raise CommandError(
314
+ "Error while linking email with IMAP ID "
315
+ f"{imap_id} to sent email: {e}"
316
+ ) from e
317
+
318
+ # Heuristic keywords commonly found in bounce messages
319
+ (bounce_type, bounce_reason) = self.analyze_bounce(msg)
320
+
321
+ # Extract all headers into a dictionary
322
+ headers = {}
323
+ for header, value in msg.items():
324
+ decoded_value, encoding = decode_header(value)[0]
325
+ if isinstance(decoded_value, bytes):
326
+ decoded_value = decoded_value.decode(
327
+ encoding or "utf-8", "ignore"
328
+ )
329
+ headers[header] = decoded_value
330
+
331
+ # Create EmailReceived object if doesn't exist
332
+ if not email_received:
333
+ overwriting = False
334
+ email_received = EmailReceived()
335
+ else:
336
+ overwriting = True
337
+
338
+ # Populate fields
339
+ email_received.imap_id = imap_id
340
+ email_received.eid = eid
341
+ email_received.efrom = efrom
342
+ email_received.eto = eto
343
+ email_received.subject = subject
344
+ email_received.headers = headers
345
+ email_received.body_text = body_plain
346
+ email_received.body_html = body_html
347
+ email_received.date_received = internal_date
348
+ email_received.email = email_message
349
+ email_received.bounce_type = bounce_type
350
+ email_received.bounce_reason = bounce_reason
351
+
352
+ # Save the received email
353
+ email_received.save()
354
+
355
+ # Count created or overwritten
356
+ if overwriting:
357
+ overwrite_count += 1
358
+ verb = "Overwritten"
359
+ else:
360
+ created_count += 1
361
+ verb = "Created"
362
+
363
+ if self.verbose:
364
+ msg = (
365
+ f"{verb} email with IMAP ID: "
366
+ f"{imap_id} (link={tracking_id})"
367
+ )
368
+ if bounce_type:
369
+ bounce_type_str = (
370
+ bounce_type == BOUNCE_HARD and "Hard" or "Soft"
371
+ )
372
+ bounce_reason_str = bounce_reason or "Unknown"
373
+ self.stdout.write(
374
+ self.style.WARNING(
375
+ f"{msg} "
376
+ f"[{bounce_type_str} bounce, "
377
+ f"reason={bounce_reason_str}]"
378
+ )
379
+ )
380
+ else:
381
+ self.stdout.write(self.style.SUCCESS(msg))
382
+
383
+ else:
384
+ if self.verbose:
385
+ self.stdout.write(
386
+ self.style.WARNING(
387
+ f"Skipping email with IMAP ID: {imap_id} (DUP)"
388
+ )
389
+ )
390
+
391
+ # Mark the message as read
392
+ # (flag \Seen) avoid reprocessing
393
+ server.add_flags(imap_id, [b"\\Seen"])
394
+
395
+ return (created_count, overwrite_count)
396
+
397
+ def find_tracking_id(self, msg: Message) -> str | None:
398
+ """
399
+ Searches for the X-Codenerix-Tracking-ID robustly in an email.
400
+
401
+ It performs the search in three steps:
402
+ 1. In the main headers of the email.
403
+ 2. In the attached parts that are a complete email (message/rfc822).
404
+ 3. As a last resort, searches the text in the body of the message.
405
+ """
406
+
407
+ # Method 1: Search in main headers (for direct replies)
408
+ tracking_id = msg.get("X-Codenerix-Tracking-ID", None)
409
+
410
+ # Method 2: Search in attached parts (for bounces and forwards)
411
+ if not tracking_id:
412
+ # Not found directly in headers
413
+ # Search in attached parts (for bounces and forwards)
414
+ if msg.is_multipart():
415
+ # Iterate through parts
416
+ for part in msg.walk():
417
+ # Get the content type of the part
418
+ content_type = part.get_content_type()
419
+
420
+ # We look for an attachment that is itself an email
421
+ if content_type == "message/rfc822":
422
+ # The payload of this part is the original email
423
+ # The payload is a list of messages, take the first one
424
+ original_msg_payload = part.get_payload()
425
+ if (
426
+ isinstance(original_msg_payload, list)
427
+ and original_msg_payload
428
+ ):
429
+ original_msg = original_msg_payload[0]
430
+ if isinstance(original_msg, Message):
431
+ tracking_id = original_msg.get(
432
+ "X-Codenerix-Tracking-ID"
433
+ )
434
+
435
+ elif content_type == "text/rfc822-headers":
436
+ # The payload is the raw headers of the original email
437
+ headers_payload = part.get_payload(decode=True)
438
+ if isinstance(headers_payload, bytes):
439
+ # Decode using the specified charset
440
+ charset = part.get_content_charset() or "utf-8"
441
+ headers_text = headers_payload.decode(
442
+ charset, errors="ignore"
443
+ )
444
+
445
+ # Parse headers text into a Message object
446
+ headers_msg = HeaderParser().parsestr(headers_text)
447
+ tracking_id = headers_msg.get(
448
+ "X-Codenerix-Tracking-ID"
449
+ )
450
+
451
+ # Method 3: Search in the body text (fallback)
452
+ if not tracking_id:
453
+ # The original email might be quoted as plain text.
454
+ body_text = ""
455
+ if msg.is_multipart():
456
+ # Concatenate all text/plain parts
457
+ for part in msg.walk():
458
+ # We only want text/plain parts
459
+ if part.get_content_type() == "text/plain":
460
+ # Get the decoded payload
461
+ payload = part.get_payload(decode=True)
462
+ if isinstance(payload, bytes):
463
+ # Decode using the specified charset
464
+ charset = part.get_content_charset() or "utf-8"
465
+ body_text += payload.decode(
466
+ charset, errors="ignore"
467
+ )
468
+ else:
469
+ # Single part email, check if it's text/plain
470
+ if msg.get_content_type() == "text/plain":
471
+ # Get the decoded payload
472
+ payload = msg.get_payload(decode=True)
473
+ if isinstance(payload, bytes):
474
+ # Decode using the specified charset
475
+ charset = msg.get_content_charset() or "utf-8"
476
+ body_text = payload.decode(
477
+ charset, errors="ignore"
478
+ )
479
+
480
+ # If we have body text, search for the header using regex
481
+ if body_text:
482
+ # We use a regex to find the header in the text
483
+ match = re.search(
484
+ r"X-Codenerix-Tracking-ID:\s*([a-fA-F0-9\-]{36})",
485
+ body_text,
486
+ )
487
+
488
+ if match:
489
+ # If found, extract the tracking ID
490
+ tracking_id = match.group(1).strip()
491
+
492
+ # Return the found tracking ID if any
493
+ return tracking_id
494
+
495
+ def analyze_bounce(
496
+ self, msg: Message
497
+ ) -> tuple[Optional[str], Optional[str]]:
498
+ """
499
+ Analyzes an email to determine if it is a bounce and of what type.
500
+
501
+ Returns:
502
+ A tuple (bounce_type, smtp_code).
503
+ - bounce_type: BOUNCE_HARD, BOUNCE_SOFT, or None if not a bounce.
504
+ - bounce_reason: the SMTP status code (e.g., '5.1.1') or None.
505
+ """
506
+
507
+ # Initialize
508
+ bounce_type: Optional[str] = None
509
+ bounce_reason: Optional[str] = None
510
+
511
+ # Method 1: Look for DSN reports
512
+ if (
513
+ msg.get_content_type() == "multipart/report"
514
+ and msg.get_param("report-type") == "delivery-status"
515
+ ):
516
+ # Iterate through parts to find the delivery-status part
517
+ for part in msg.walk():
518
+ # We look for the delivery-status part
519
+ if part.get_content_type() == "message/delivery-status":
520
+ # The payload is a list of headers
521
+ payload = part.get_payload()
522
+ if payload and isinstance(payload, list):
523
+ # The first part contains the status headers
524
+ status_headers = payload[0]
525
+ if isinstance(status_headers, Message):
526
+ # Extract Action and Status headers
527
+ action = status_headers.get("Action", "").lower()
528
+ status_code = status_headers.get("Status", "")
529
+
530
+ # Check if action indicates failure
531
+ if action == "failed":
532
+ # Determine Hard/Soft by SMTP code (RFC3463)
533
+ if status_code.startswith("5."):
534
+ # 5.x.x: permanent failure (hard)
535
+ bounce_type = BOUNCE_HARD
536
+ bounce_reason = status_code
537
+ break
538
+ elif status_code.startswith("4."):
539
+ # 4.x.x: temporary failure (soft)
540
+ bounce_type = BOUNCE_SOFT
541
+ bounce_reason = status_code
542
+ break
543
+ else:
544
+ # Unknown status, assume hard bounce
545
+ bounce_type = BOUNCE_HARD
546
+ bounce_reason = status_code or "Unknown"
547
+ break
548
+
549
+ # Method 2: Some mail servers include headers indicating a bounce
550
+ if not bounce_type:
551
+ if msg.get("X-Failed-Recipients"):
552
+ # Presence of this header usually indicates a hard bounce
553
+ bounce_type = BOUNCE_HARD
554
+ bounce_reason = "Unknown (X-Failed-Recipients)"
555
+
556
+ else:
557
+ # Check for Auto-Submitted header
558
+ if msg.get("Auto-Submitted", "").lower() in (
559
+ "auto-replied",
560
+ "auto-generated",
561
+ ):
562
+ # It could be a bounce, but also an "Out of Office",
563
+ # so we combine it with a keyword search.
564
+ subject = msg.get("Subject", "").lower()
565
+ bounce_keywords = [
566
+ "undeliverable",
567
+ "delivery failed",
568
+ "failure notice",
569
+ ]
570
+
571
+ # If we find bounce keywords in the subject
572
+ if any(keyword in subject for keyword in bounce_keywords):
573
+ # Assume is a hard bounce
574
+ bounce_type = BOUNCE_HARD
575
+ bounce_reason = "Unknown (Auto-Submitted + Keyword)"
576
+
577
+ # Method 3: keyword search (less reliable)
578
+ if not bounce_type:
579
+ # We look for common bounce keywords in the From or Subject headers
580
+ # We avoid false positives by requiring specific keywords.
581
+ from_header = msg.get("From", "").lower()
582
+ subject_header = msg.get("Subject", "").lower()
583
+
584
+ if "mailer-daemon@" in from_header or "postmaster@" in from_header:
585
+ # Common bounce sender addresses
586
+ bounce_type = BOUNCE_HARD
587
+ bounce_reason = "Unknown (From Keyword)"
588
+ else:
589
+ # Check subject for common bounce keywords
590
+ bounce_keywords = [
591
+ "undelivered",
592
+ "delivery error",
593
+ "mail delivery failed",
594
+ ]
595
+ if any(
596
+ keyword in subject_header for keyword in bounce_keywords
597
+ ):
598
+ bounce_type = BOUNCE_HARD
599
+ bounce_reason = "Unknown (Subject Keyword)"
600
+
601
+ return (bounce_type, bounce_reason)