Skip to content

Commit 1de60ff

Browse files
IMAP: add last docstrings
1 parent 850d21c commit 1de60ff

File tree

2 files changed

+160
-65
lines changed

2 files changed

+160
-65
lines changed

src/protocols/imap_object.py

Lines changed: 157 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,18 @@ def get_body(self, preferencelist=('related', 'html', 'plain')) -> str:
241241

242242
return content
243243

244-
def is_in(self, query_list, field:str, case_sensitive:bool=False, mode:str="any"):
245-
# Check if any or all of the elements in the query_list is in the email field
246-
# The field can by any RFC header or "Body".
244+
def is_in(self, query_list: list[str] | str, field:str, case_sensitive:bool=False, mode:str="any") -> bool:
245+
"""Check if any or all of the elements in the `query_list` is in the email `field`.
246+
247+
Arguments:
248+
query_list (list[str] | str): list of keywords or unique keyword to find in `field`.
249+
field (str): any RFC 822 header or `"body"`.
250+
case_sensitive (str): `True` if the search should be case-sensitive. This has no effect if `field` is a RFC 822 header, it only applies to the email body.
251+
mode (str): `"any"` if any element in `query_list` should be found in `field` to return `True`. `"all"` if all elements in `query_list` should be found in `field` to return `True`.
252+
253+
Returns:
254+
(bool): `True` if any or all elements (depending on `mode`) of `query_list` have been found in `field`.
255+
"""
247256
value = None
248257
if field.lower() == "body":
249258
value = self.get_body() if case_sensitive else self.get_body().lower()
@@ -263,15 +272,21 @@ def is_in(self, query_list, field:str, case_sensitive:bool=False, mode:str="any"
263272
elif mode == "all":
264273
return all(query_item in value for query_item in query_list)
265274
else:
266-
raise ValueError("Non-implementad mode `%s` used" % mode)
275+
raise ValueError("Non-implemented mode `%s` used" % mode)
267276

268277

269278
##
270279
## IMAP ACTIONS
271280
##
272281

273282
def tag(self, keyword:str):
274-
# Add a tag.
283+
"""Add any arbitrary IMAP tag (aka label), standard or not, to the current email.
284+
285+
Warning:
286+
In Mozilla Thunderbird, labels/tags need to be configured first in the preferences (by mapping the label string to a color) to properly appear in the GUI. Otherwise, any undefined tag will be identified as "Important" (associated with red), no matter its actual string.
287+
288+
Horde, Roundcube and Nextcloud mail (based on Horde) treat those properly.
289+
"""
275290
result = self.server.uid('STORE', self.uid, '+FLAGS', keyword)
276291

277292
if result[0] == "OK":
@@ -284,8 +299,8 @@ def tag(self, keyword:str):
284299

285300
self.server.std_out = result
286301

287-
def untag(self, keyword):
288-
# Remove a tag.
302+
def untag(self, keyword: str):
303+
"""Remove any arbitrary IMAP tag (aka label), standard or not, to the current email."""
289304
result = self.server.uid('STORE', self.uid, '-FLAGS', keyword)
290305

291306
if result[0] == "OK":
@@ -299,8 +314,13 @@ def untag(self, keyword):
299314
self.server.std_out = result
300315

301316
def delete(self):
302-
# Delete an email directly without using the trash bin.
303-
# Use a move to trash folder to get a last chance at reviewing what will be deleted.
317+
"""Delete the current email directly without using the trash bin. It will not be recoverable.
318+
319+
Use [EMail.move][protocols.imap_object.EMail.move] to move the email to the trash folder to get a last chance at reviewing what will be deleted.
320+
321+
Note:
322+
As per IMAP standard, this only add the `\\Deleted` flag to the current email. Emails will be actually deleted when the `expunge` server command is launched, which is done automatically at the end of [Server.run_filters][protocols.imap_server.Server.run_filters].
323+
"""
304324
result = self.server.uid('STORE', self.uid, '+FLAGS', "(\\Deleted)")
305325

306326
if result[0] == "OK":
@@ -316,7 +336,7 @@ def delete(self):
316336
self.server.std_out = result
317337

318338
def spam(self, spam_folder="INBOX.spam"):
319-
# Mark an email as spam using Thunderbird tags and move it to the spam/junk folder
339+
"""Mark the current email as spam, adding Mozilla Thunderbird `Junk` flag, and move it to the spam/junk folder."""
320340
self.server.uid('STORE', self.uid, '-FLAGS', 'NonJunk')
321341
self.server.uid('STORE', self.uid, '+FLAGS', 'Junk')
322342
self.server.uid('STORE', self.uid, '-FLAGS', '(\\Seen)')
@@ -336,6 +356,8 @@ def spam(self, spam_folder="INBOX.spam"):
336356
self.server.std_out = result
337357

338358
def move(self, folder:str):
359+
"""Move the current email to the target `folder`, that will be created recursively if it does not exist. `folder` will be internally encoded to IMAP-custom UTF-7 with [Server.encode_imap_folder][protocols.imap_server.Server.encode_imap_folder].
360+
"""
339361
self.server.create_folder(folder)
340362
result = self.server.uid('COPY', self.uid, self.server.encode_imap_folder(folder))
341363

@@ -356,7 +378,12 @@ def move(self, folder:str):
356378

357379

358380
def mark_as_important(self, mode:str):
359-
# Flag or unflag an email as important
381+
"""Flag or unflag an email as important
382+
383+
Arguments:
384+
mode (str): `add` to add the `\\Flagged` IMAP tag to the current email, `remove` to remove it.
385+
386+
"""
360387
tag = "(\\Flagged)"
361388
if mode == "add":
362389
self.tag(tag)
@@ -365,7 +392,12 @@ def mark_as_important(self, mode:str):
365392

366393

367394
def mark_as_read(self, mode:str):
368-
# Flag or unflag an email as read (seen)
395+
"""Flag or unflag an email as read (seen).
396+
397+
Arguments:
398+
mode (str): `add` to add the `\\Seen` IMAP tag to the current email, `remove` to remove it.
399+
400+
"""
369401
tag = "(\\Seen)"
370402
if mode == "add":
371403
self.tag(tag)
@@ -374,12 +406,14 @@ def mark_as_read(self, mode:str):
374406

375407

376408
def mark_as_answered(self, mode:str):
377-
# Flag or unflag an email as answered
378-
# Note :
379-
# if you answer programmatically, you need to manually pass the Message-ID of the original email
380-
# to the In-Reply-To and Referencess of the answer to get threaded messages. In-Reply-To gets only
381-
# the immediate previous email, References get the whole thread.
409+
"""Flag or unflag an email as answered.
382410
411+
Arguments:
412+
mode (str): `add` to add the `\\Answered` IMAP tag to the current email, `remove` to remove it.
413+
414+
Note :
415+
if you answer programmatically, you need to manually pass the Message-ID of the original email to the In-Reply-To and Referencess of the answer to get threaded messages. In-Reply-To gets only the immediate previous email, References get the whole thread.
416+
"""
383417
tag = "(\\Answered)"
384418
if mode == "add":
385419
self.tag(tag)
@@ -392,40 +426,49 @@ def mark_as_answered(self, mode:str):
392426
##
393427

394428
def is_read(self) -> bool:
395-
# Email has been opened and read
429+
"""Check if this email has been opened and read."""
396430
return "\\Seen" in self.flags
397431

398432
def is_unread(self) -> bool:
399-
# Email has not been opened and read
433+
"""Check if this email has not been yet opened and read."""
400434
return "\\Seen" not in self.flags
401435

402436
def is_recent(self) -> bool:
403-
# This session is the first one to get this email. Doesn't mean user read it.
404-
# Note : this flag cannot be set by client, only by server. It's read-only app-wise.
437+
"""Check if this session is the first one to get this email. It doesn't mean user read it.
438+
439+
Note:
440+
this flag cannot be set by client, only by server. It's read-only app-wise.
441+
"""
405442
return "\\Recent" in self.flags
406443

407444
def is_draft(self) -> bool:
408-
# This email is maked as draft
445+
"""Check if this email is maked as draft."""
409446
return "\\Draft" in self.flags
410447

411448
def is_answered(self) -> bool:
412-
# This email has been answered
449+
"""Check if this email has been answered."""
413450
return "\\Answered" in self.flags
414451

415452
def is_important(self) -> bool:
416-
# This email has been flagged as important
453+
"""Check if this email has been flagged as important."""
417454
return "\\Flagged" in self.flags
418455

419456
def is_mailing_list(self) -> bool:
420-
# This email has the typical mailing-list tags. Warning: this is not standard and not systematically used.
421-
# Mailing list are sent by humans to a group of other humans.
457+
"""Check if this email has the typical mailing-list headers.
458+
459+
Warning:
460+
The headers checked for hints here are not standard and not systematically used.
461+
"""
422462
has_list_unsubscribe = self.has_header("List-Unsubscribe") # note that we don't check if it's a valid unsubscribe link
423463
has_precedence_list = self.has_header("Precedence") and self["Precedence"] == "list"
424464
return has_list_unsubscribe and has_precedence_list
425465

426466
def is_newsletter(self) -> bool:
427-
# This email has the typical newsletter tags. Warning: this is not standard and not systematically used.
428-
# Newsletters are sent by bots to a group of humans.
467+
"""Check if this email has the typical newsletter headers.
468+
469+
Warning:
470+
The headers checked for hints here are not standard and not systematically used.
471+
"""
429472
has_list_id = self.has_header("List-ID")
430473
has_precedence_bulk = self.has_header("Precedence") and self["Precedence"] == "bulk"
431474
has_feedback_id = self.has_header("Feedback-ID") or self.has_header("X-Feedback-ID")
@@ -441,8 +484,22 @@ def has_tag(self, tag:str) -> bool:
441484
##
442485

443486
def spf_pass(self) -> int:
444-
# Check if any of the servers listed by IP in the "Received" header is authorized
445-
# by the mail server to send emails on behalf of the email address used as "From".
487+
"""Check if any of the servers listed in the `Received` email headers is authorized by the DNS SPF rules to send emails on behalf of the email address set in `Return-Path`.
488+
489+
Returns:
490+
score:
491+
- `= 0`: neutral result, no explicit success or fail, or server configuration could not be retrieved/interpreted.
492+
- `> 0`: success, server is explicitly authorized or SPF rules are deliberately permissive.
493+
- `< 0`: fail, server is unauthorized.
494+
- `= 2`: explicit success, server is authorized.
495+
- `= -2`: explicit fail, server is forbidden, the email is a deliberate spoofing attempt.
496+
497+
Note:
498+
The `Return-Path` header is set by any proper mail client to the mailbox collecting bounces (notice of undelivered emails), and, while it is optional, the [RFC 4408](https://www.ietf.org/rfc/rfc4408.txt) states that it is the one from which the SPF domain will be inferred. In practice, it is missing only in certain spam messages, so its absence is treated as an explicit fail.
499+
500+
Warning:
501+
Emails older than 6 months will at least get a score of `0` and will therefore never fail the SPF check. This is because DNS configuration may have changed since the email was sent, and it could have been valid at the time of sending.
502+
"""
446503
# See https://www.rfc-editor.org/rfc/rfc7208
447504
# Output a reputation score :
448505
scores = { "none": 0, # no SPF records were retrieved from the DNS.
@@ -514,14 +571,22 @@ def spf_pass(self) -> int:
514571
# Otherwise return neutral score. This is because server DKIM/ARC keys, MX and SPF may have changed.
515572
return 0 if self.age() > timedelta(days=30 * 6) and spf_score < 0 else spf_score
516573

517-
def dkim_pass(self):
518-
# Return a reputation score :
519-
# 0 if no DKIM signature
520-
# 1 if the DKIM signature is valid but outdated
521-
# 2 if the DKIM signature is valid and up-to-date
522-
# -2 if the DKIM signature is invalid. That's because many spammers
523-
# forge a fake Google DKIM signature hoping to past by the spam filters
524-
# that only check for the header presence without actually validating it.
574+
def dkim_pass(self) -> int:
575+
"""Check the authenticity of the DKIM signature.
576+
577+
Note:
578+
The DKIM signature uses an asymetric key scheme, where the private key is set on the SMTP server and the public key is set in DNS records of the mailserver. The signature is a cryptographic hash of the email headers (not their content). A valid signature means the private key used to hash headers matches the public key in the DNS records AND the headers have not been tampered with since sending.
579+
580+
Returns:
581+
score:
582+
- `= 0`: there is no DKIM signature.
583+
- `= 1`: the DKIM signature is valid but outdated. This means the public key in DNS records has been updated since they email was sent.
584+
- `= 2`: the DKIM signature is valid and up-to-date.
585+
- `= -2`: the DKIM signature is invalid. Either the headers have been tampered or the DKIM signature is entirely forged (happens a lot in spam emails).
586+
587+
Warning:
588+
Emails older than 6 months will at least get a score of `0` and will therefore never fail the DKIM check. This is because DNS configuration (public key) may have changed since the email was sent, and it could have been valid at the time of sending.
589+
"""
525590

526591
if self.has_header("DKIM-Signature"):
527592
dkim_score = -2
@@ -553,14 +618,18 @@ def dkim_pass(self):
553618
# Otherwise return neutral score. This is because server DKIM/ARC keys, MX and SPF may have changed.
554619
return 0 if self.age() > timedelta(days=30 * 6) and dkim_score < 0 else dkim_score
555620

556-
def arc_pass(self):
557-
# Return a reputation score :
558-
# 0 if no ARC signature
559-
# 2 if the ARC signature is valid
560-
# -2 if the ARC signature is invalid. That's because many spammers
561-
# forge a fake Google ARC signature hoping to past by the spam filters
562-
# that only check for the header presence without actually validating it.
621+
def arc_pass(self) -> int:
622+
"""Check the authenticity of the ARC signature.
623+
624+
Note:
625+
The ARC signature is still experimental and not widely used. When an email is forwarded, by an user or through a mailing list, its DKIM signature will be invalidated and the email will appear forged/tampered. ARC authentifies the intermediate servers and aims at solving this issue.
563626
627+
Returns:
628+
score:
629+
- `= 0`: there is no ARC signature,
630+
- `= 2`: the ARC signature is valid
631+
- `=-2`: the ARC signature is invalid. Typically, it means the signature has been forged.
632+
"""
564633
if self.has_header("ARC-Message-Signature") and \
565634
self.has_header("ARC-Seal") and \
566635
self.has_header("ARC-Authentication-Results"):
@@ -585,11 +654,15 @@ def arc_pass(self):
585654
return 0 if self.age() > timedelta(days=30 * 6) and arc_score < 0 else arc_score
586655

587656
def authenticity_score(self) -> int:
588-
# Returns :
589-
# == 0 : neutral, no explicit authentification is defined on DNS or no rule could be found
590-
# > 0 : explicitly authenticated
591-
# == 6 : maximal authenticity (valid SPF and valid DKIM)
592-
# < 0 : spoofed, either or both SPF and DKIM explicitely failed
657+
"""Compute the score of authenticity of the email, summing the results of [EMail.spf_pass][protocols.imap_object.EMail.spf_pass], [EMail.dkim_pass][protocols.imap_object.EMail.dkim_pass] and [EMail.arc_pass][protocols.imap_object.EMail.arc_pass]. The weighting is designed such that one valid check compensates one fail.
658+
659+
Returns:
660+
score:
661+
- `== 0`: neutral, no explicit authentification is defined on DNS or no rule could be found
662+
- `> 0`: explicitly authenticated by at least one method,
663+
- `== 6`: maximal authenticity (valid SPF, DKIM and ARC)
664+
- `< 0`: spoofed, at least one of SPF or DKIM or ARC failed and
665+
"""
593666
spf_score = int(self.spf_pass())
594667
dkim_score = int(self.dkim_pass())
595668
arc_score = int(self.arc_pass())
@@ -598,23 +671,30 @@ def authenticity_score(self) -> int:
598671
return total
599672

600673
def is_authentic(self) -> bool:
601-
# Check SPF and DKIM to validate that the email is authentic,
602-
# aka not spoofed. That's enough to detect most spams.
674+
"""Helper function for [EMail.authenticity_score][protocols.imap_object.EMail.authenticity_score], checking if at least one authentication method succeeded.
675+
676+
Returns:
677+
True if [EMail.authenticity_score][protocols.imap_object.EMail.authenticity_score] returns a score greater or equal to zero.
678+
"""
603679
return self.authenticity_score() >= 0
604680

605681
##
606682
## Utils
607683
##
608684

609-
def age(self):
610-
# Compute the age of an email at the time of evaluation
685+
def age(self) -> timedelta:
686+
"""Compute the age of an email at the time of evaluation
687+
688+
Returns:
689+
time difference between current time and sending time of the email
690+
"""
611691
current_date = datetime.now(timezone.utc)
612692
delta = (current_date - self.date)
613693
return delta
614694

615695

616-
def now(self):
617-
# Helper to get access to date/time from within the email object when writing filters
696+
def now(self) -> str:
697+
"""Helper to get access to date/time from within the email object when writing filters"""
618698
return utils.now()
619699

620700

@@ -684,10 +764,14 @@ def create_hash(self):
684764
self.hash = timestamp + "-" + hash
685765

686766

687-
def query_referenced_emails(self) -> list:
688-
# Fetch the list of all emails referenced in the present message,
689-
# aka the whole email thread in wich the current email belongs.
690-
# The list is sorted from newest to oldest.
767+
def query_referenced_emails(self) -> list[EMail]:
768+
"""Fetch the list of all emails referenced in the present message, aka the whole email thread in wich the current email belongs.
769+
770+
The list is sorted from newest to oldest. Queries emails having a `Message-ID` header matching the ones contained in the `References` header of the current email.
771+
772+
Returns:
773+
All emails referenced.
774+
"""
691775
thread = []
692776
if self.has_header("References"):
693777
for id in self["References"].split(" "):
@@ -721,8 +805,13 @@ def query_referenced_emails(self) -> list:
721805
return thread
722806

723807

724-
def query_replied_email(self):
725-
# Fetch the email being replied to by the current email.
808+
def query_replied_email(self) -> EMail:
809+
"""Fetch the email being replied to by the current email.
810+
811+
Returns:
812+
The email being replied to.
813+
814+
"""
726815
replied = None
727816

728817
if self.has_header("In-Reply-To"):
@@ -800,8 +889,13 @@ def __init__(self, raw_message:list, server):
800889
super().__init__(raw_message, server)
801890

802891
self.urls = []
892+
"""`list(tuple(str))` List of URLs found in email body."""
893+
803894
self.ips = []
895+
"""`list(str)` List of IPs found in the server delivery route (in `Received` headers)"""
896+
804897
self.domains = []
898+
"""`list(str)` List of domains found in the server delivery route (in `Received` headers)"""
805899

806900
# Raw message as fetched by IMAP, decode IMAP headers
807901
try:
@@ -818,7 +912,7 @@ def __init__(self, raw_message:list, server):
818912
# No exception handling here, let it fail. Email validity should be checked at server level
819913
self.raw = raw_message[1]
820914
self.msg : email.message.EmailMessage = email.message_from_bytes(self.raw, policy=policy.default)
821-
"""(email.message.EmailMessage) standard Python email object"""
915+
"""`(email.message.EmailMessage)` standard Python email object"""
822916

823917
# Get "a" date for the email
824918
self.get_date()

0 commit comments

Comments
 (0)