envelope 2.2.0__tar.gz → 2.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: envelope
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Insert a message and attachments and send e-mail / sign / encrypt contents by a single line.
5
5
  License: LGPL-3.0-or-later
6
6
  Author: Edvard Rejthar
@@ -23,7 +23,7 @@ Description-Content-Type: text/markdown
23
23
 
24
24
  [![Build Status](https://github.com/CZ-NIC/envelope/actions/workflows/run-unittest.yml/badge.svg)](https://github.com/CZ-NIC/envelope/actions) [![Downloads](https://static.pepy.tech/badge/envelope)](https://pepy.tech/project/envelope)
25
25
 
26
- Quick layer over [python-gnupg](https://bitbucket.org/vinay.sajip/python-gnupg/src), [M2Crypto](https://m2crypto.readthedocs.io/), [smtplib](https://docs.python.org/3/library/smtplib.html), [magic](https://pypi.org/project/python-magic/) and [email](https://docs.python.org/3/library/email.html?highlight=email#module-email) handling packages. Their common use cases merged into a single function. Want to sign a text and tired of forgetting how to do it right? You do not need to know everything about GPG or S/MIME, you do not have to bother with importing keys. Do not hassle with reconnecting to an SMTP server. Do not study various headers meanings to let your users unsubscribe via a URL.
26
+ Quick layer over [python-gnupg](https://bitbucket.org/vinay.sajip/python-gnupg/src), [cryptography](https://github.com/pyca/cryptography), [M2Crypto](https://m2crypto.readthedocs.io/), [smtplib](https://docs.python.org/3/library/smtplib.html), [magic](https://pypi.org/project/python-magic/) and [email](https://docs.python.org/3/library/email.html?highlight=email#module-email) handling packages. Their common use cases merged into a single function. Want to sign a text and tired of forgetting how to do it right? You do not need to know everything about GPG or S/MIME, you do not have to bother with importing keys. Do not hassle with reconnecting to an SMTP server. Do not study various headers meanings to let your users unsubscribe via a URL.
27
27
  You insert a message, attachments and inline images and receive signed and/or encrypted output to the file or to your recipients' e-mail.
28
28
  Just single line of code. With the great help of the examples below.
29
29
 
@@ -692,6 +692,16 @@ if xarf := Envelope.load(path="xarf.eml")._report():
692
692
  print(xarf['SourceIp']) # '192.0.2.1'
693
693
  ```
694
694
 
695
+ * `_check_auth()`: To determine whether DMACR, SPF and DKIM are alright in a loaded message.
696
+ ```python3
697
+ from envelope import Envelope
698
+ auth = Envelope.load(path="test.eml")._check_auth()
699
+ auth # <AuthResult spf='pass', dkim='pass', dmarc='pass', spf_received='pass', verdict='pass', failure_reason=None>
700
+ if auth: # Use the output object as bool. It is True only if all present checks are 'pass'.
701
+ ...
702
+ ```
703
+
704
+
695
705
  ## Envelope object
696
706
 
697
707
  ### Converting object to str or bool
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![Build Status](https://github.com/CZ-NIC/envelope/actions/workflows/run-unittest.yml/badge.svg)](https://github.com/CZ-NIC/envelope/actions) [![Downloads](https://static.pepy.tech/badge/envelope)](https://pepy.tech/project/envelope)
4
4
 
5
- Quick layer over [python-gnupg](https://bitbucket.org/vinay.sajip/python-gnupg/src), [M2Crypto](https://m2crypto.readthedocs.io/), [smtplib](https://docs.python.org/3/library/smtplib.html), [magic](https://pypi.org/project/python-magic/) and [email](https://docs.python.org/3/library/email.html?highlight=email#module-email) handling packages. Their common use cases merged into a single function. Want to sign a text and tired of forgetting how to do it right? You do not need to know everything about GPG or S/MIME, you do not have to bother with importing keys. Do not hassle with reconnecting to an SMTP server. Do not study various headers meanings to let your users unsubscribe via a URL.
5
+ Quick layer over [python-gnupg](https://bitbucket.org/vinay.sajip/python-gnupg/src), [cryptography](https://github.com/pyca/cryptography), [M2Crypto](https://m2crypto.readthedocs.io/), [smtplib](https://docs.python.org/3/library/smtplib.html), [magic](https://pypi.org/project/python-magic/) and [email](https://docs.python.org/3/library/email.html?highlight=email#module-email) handling packages. Their common use cases merged into a single function. Want to sign a text and tired of forgetting how to do it right? You do not need to know everything about GPG or S/MIME, you do not have to bother with importing keys. Do not hassle with reconnecting to an SMTP server. Do not study various headers meanings to let your users unsubscribe via a URL.
6
6
  You insert a message, attachments and inline images and receive signed and/or encrypted output to the file or to your recipients' e-mail.
7
7
  Just single line of code. With the great help of the examples below.
8
8
 
@@ -671,6 +671,16 @@ if xarf := Envelope.load(path="xarf.eml")._report():
671
671
  print(xarf['SourceIp']) # '192.0.2.1'
672
672
  ```
673
673
 
674
+ * `_check_auth()`: To determine whether DMACR, SPF and DKIM are alright in a loaded message.
675
+ ```python3
676
+ from envelope import Envelope
677
+ auth = Envelope.load(path="test.eml")._check_auth()
678
+ auth # <AuthResult spf='pass', dkim='pass', dmarc='pass', spf_received='pass', verdict='pass', failure_reason=None>
679
+ if auth: # Use the output object as bool. It is True only if all present checks are 'pass'.
680
+ ...
681
+ ```
682
+
683
+
674
684
  ## Envelope object
675
685
 
676
686
  ### Converting object to str or bool
@@ -171,7 +171,11 @@ def main():
171
171
  or args["subject"] is True \
172
172
  or args["message"] is True \
173
173
  or select.select([sys.stdin, ], [], [], 0.0)[0]:
174
- instance = Envelope.load(sys.stdin.read())
174
+ stdin = sys.stdin.read()
175
+ if "\r" in stdin or "\n" in stdin:
176
+ instance = Envelope.load(stdin)
177
+ else:
178
+ instance = Envelope(message=stdin)
175
179
  except select.error: # XX check if using `select` to detect STDIN does not mess up Windows
176
180
  pass
177
181
  del args["load"]
@@ -0,0 +1,79 @@
1
+ from dataclasses import dataclass
2
+ from email.message import EmailMessage
3
+ import re
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class AuthResult:
9
+ verdict: str
10
+ spf: Optional[str] = None
11
+ dkim: Optional[str] = None
12
+ dmarc: Optional[str] = None
13
+ spf_received: Optional[str] = None
14
+ failure_reason: Optional[str] = None
15
+
16
+ def __bool__(self):
17
+ return self.verdict == "pass"
18
+
19
+ def __repr__(self):
20
+ return (f"<AuthResult spf={self.spf!r}, dkim={self.dkim!r}, dmarc={self.dmarc!r}, "
21
+ f"spf_received={self.spf_received!r}, verdict={self.verdict!r}, failure_reason={self.failure_reason!r}>")
22
+
23
+ @staticmethod
24
+ def _extract_results_from_text(text: str) -> dict:
25
+ def extract(key: str):
26
+ match = re.search(fr"{key}=([\w\-]+)", text, re.IGNORECASE)
27
+ return match.group(1).lower() if match else None
28
+
29
+ return extract("spf"), extract("dkim"), extract("dmarc")
30
+
31
+ @staticmethod
32
+ def from_headers(headers: EmailMessage) -> "AuthResult":
33
+
34
+ # Merge Authentication-Results and ARC-Authentication-Results hlavičky dohromady
35
+ auth_results_headers: list[str] = []
36
+ auth_results_headers += headers.get_all("Authentication-Results", [])
37
+ auth_results_headers += headers.get_all("ARC-Authentication-Results", [])
38
+
39
+ combined_auth_results = "; ".join(auth_results_headers)
40
+
41
+ # Extract from text
42
+ spf, dkim, dmarc = AuthResult._extract_results_from_text(combined_auth_results)
43
+
44
+ # SPF from Received-SPF
45
+ received_spf = headers.get("Received-SPF", "")
46
+ spf_received_match = re.search(r"([\w\-]+)", received_spf)
47
+ spf_received = spf_received_match.group(1).lower() if spf_received_match else None
48
+
49
+ # Determine the verdict
50
+ failure_reason = None
51
+ if spf and spf_received and spf != spf_received:
52
+ verdict = "fail"
53
+ failure_reason = f"SPF mismatch: Authentication-Results={spf}, Received-SPF={spf_received}"
54
+
55
+ elif dmarc == "pass":
56
+ verdict = "pass"
57
+
58
+ elif any(v == "fail" for v in [spf, dkim, dmarc, spf_received]):
59
+ verdict = "fail"
60
+ failure_reason = "At least one mechanism failed"
61
+
62
+ elif any(v == "softfail" for v in [spf, spf_received]):
63
+ verdict = "softfail"
64
+ failure_reason = "SPF softfail detected"
65
+
66
+ elif all(v == "pass" for v in [spf, dkim] if v and v != 'none'):
67
+ verdict = "pass"
68
+ else:
69
+ verdict = "none"
70
+ failure_reason = "No usable authentication results"
71
+
72
+ return AuthResult(
73
+ spf=spf,
74
+ dkim=dkim,
75
+ dmarc=dmarc,
76
+ spf_received=spf_received,
77
+ verdict=verdict,
78
+ failure_reason=failure_reason
79
+ )
@@ -24,6 +24,7 @@ from quopri import decodestring
24
24
  from types import GeneratorType
25
25
  from typing import Literal, Union, Optional, Any
26
26
 
27
+ from ._auth import AuthResult
27
28
  from .address import Address, _getaddresses
28
29
  from .attachment import Attachment
29
30
  from .constants import ISSUE_LINK, smime_import_error, gnupg, CRLF, AUTO, PLAIN, HTML, SIMULATION, SAFE_LOCALE
@@ -205,13 +206,25 @@ class Envelope:
205
206
  :param key: S/MIME key to decrypt with.
206
207
  :param cert: S/MIME cert to decrypt with. (If not bundled with the key.)
207
208
  :param gnupg_home: Path to the GNUPG_HOME or None if the environment default should be used.
209
+
210
+ :raise ValueError: If the message cannot be loaded.
208
211
  """
209
212
  if path:
210
213
  message = Path(path)
211
214
  elif isinstance(message, Message):
212
215
  message = str(message)
213
216
 
214
- o = message_from_bytes(assure_fetched(message, bytes))
217
+ msg_ = assure_fetched(message, bytes)
218
+ o = message_from_bytes(msg_)
219
+
220
+ # NOTE this breaks up .load("hello text")
221
+ # quick check
222
+ for _ in o.keys():
223
+ break
224
+ else: # there are no headers
225
+ if not path and b"\n" not in msg_ and b"\r" not in msg_:
226
+ raise ValueError(f"Message cannot be loaded. Didn't you forgot to use the `Envelope.load(path=...)`?")
227
+
215
228
  e = Envelope()
216
229
  try:
217
230
  return Parser(e, key=key, cert=cert, gnupg_home=gnupg_home or e._get_gnupg_home()) \
@@ -406,7 +419,8 @@ class Envelope:
406
419
  split_addresses = []
407
420
  for address in addresses:
408
421
  split_addresses.extend(address.replace(';', ',').split(','))
409
- split_addresses = [x.strip() for x in split_addresses if x.strip()] # remove empty and whitespace-only strings
422
+ # remove empty and whitespace-only strings
423
+ split_addresses = [x.strip() for x in split_addresses if x.strip()]
410
424
  registry += (a for a in Address.parse(split_addresses) if a not in registry)
411
425
 
412
426
  def to(self, email_or_more=None) -> Union["Envelope", list[Address]]:
@@ -607,7 +621,8 @@ class Envelope:
607
621
  :param one_click: If True, rfc8058 List-Unsubscribe-Post header is added.
608
622
  This says user can unsubscribe with a single click that is realized by a POST request
609
623
  in order to prevent e-mail scanner to access the unsubscribe page by mistake. A 'https' url must be present.
610
- :param web: URL. Ex: "example.com/unsubscribe", "http://example.com/unsubscribe"
624
+ :param web: URL. Ex: "https://example.com/unsubscribe"
625
+ Without protocol, `https://` is prepended, "example.com/unsubscribe" -> "https://example.com/unsubscribe"
611
626
  :param email: E-mail address. Ex: "me@example.com", "mailto:me@example.com"
612
627
  :return: self
613
628
  """
@@ -1260,29 +1275,32 @@ class Envelope:
1260
1275
  def _import_cryptoraphy_modules(self):
1261
1276
  try:
1262
1277
  from cryptography.hazmat.primitives.serialization import load_pem_private_key, pkcs7
1263
- from cryptography.x509 import load_pem_x509_certificate
1278
+ from cryptography.x509 import load_pem_x509_certificates, load_pem_x509_certificate
1264
1279
  from cryptography.hazmat.primitives import hashes, serialization
1265
- return load_pem_private_key, pkcs7, load_pem_x509_certificate, hashes, serialization
1280
+ return load_pem_private_key, pkcs7, load_pem_x509_certificates, load_pem_x509_certificate, hashes, serialization
1266
1281
  except ImportError:
1267
1282
  raise ImportError(smime_import_error)
1268
-
1283
+
1269
1284
  def smime_sign_only(self, email, sign):
1270
- load_pem_private_key, pkcs7, load_pem_x509_certificate, hashes, serialization = self._import_cryptoraphy_modules()
1285
+ load_pem_private_key, pkcs7, load_pem_x509_certificates, _, hashes, serialization = self._import_cryptoraphy_modules()
1271
1286
  # get sender's cert
1272
1287
  # cert and private key can be one file
1273
1288
 
1274
1289
  # if certfile is not provided, sign has to have both private key and certificate
1275
1290
  # cert is not in sign (--sign_path file), it is cert.pem
1276
1291
  if self._cert:
1277
- cert = self._cert
1292
+ certs = self._cert
1278
1293
  else:
1279
1294
  # certificate is in sign (--sign_path file), it is key-cert-together.pem
1280
- cert = sign
1295
+ certs = sign
1281
1296
 
1282
1297
  try:
1283
- cert = load_pem_x509_certificate(cert)
1298
+ certs = load_pem_x509_certificates(certs)
1284
1299
  except ValueError as e:
1285
1300
  raise ValueError(f"Certificate not found: {e}")
1301
+ # no certificate found
1302
+ if len(certs) == 0:
1303
+ raise ValueError("Certificate not found")
1286
1304
 
1287
1305
  # get senders private key for signing
1288
1306
  try:
@@ -1297,29 +1315,36 @@ class Envelope:
1297
1315
 
1298
1316
  options = [pkcs7.PKCS7Options.DetachedSignature]
1299
1317
 
1300
- signed_email = pkcs7.PKCS7SignatureBuilder().set_data(
1318
+ signature_builder = pkcs7.PKCS7SignatureBuilder().set_data(
1301
1319
  email
1302
1320
  ).add_signer(
1303
- cert, key, hashes.SHA256()
1304
- ).sign(
1321
+ certs[0], key, hashes.SHA256()
1322
+ )
1323
+ # add the whole certificate chain
1324
+ for cert in certs[1:]:
1325
+ signature_builder = signature_builder.add_certificate(cert)
1326
+
1327
+ signed_email = signature_builder.sign(
1305
1328
  serialization.Encoding.SMIME, options
1306
1329
  )
1307
1330
 
1308
1331
  return signed_email
1309
1332
 
1310
1333
  def smime_sign_encrypt(self, email, sign, encrypt):
1311
- load_pem_private_key, pkcs7, load_pem_x509_certificate, hashes, serialization = self._import_cryptoraphy_modules()
1334
+ load_pem_private_key, pkcs7, load_pem_x509_certificates, load_pem_x509_certificate, hashes, serialization = self._import_cryptoraphy_modules()
1312
1335
 
1313
1336
  if self._cert:
1314
- sender_cert = self._cert
1337
+ sender_certs = self._cert
1315
1338
  else:
1316
1339
  # certificate is in sign (--sign_path file), it is key-cert-together.pem
1317
- sender_cert = sign
1340
+ sender_certs = sign
1318
1341
 
1319
1342
  try:
1320
- sender_cert = load_pem_x509_certificate(sender_cert)
1343
+ sender_certs = load_pem_x509_certificates(sender_certs)
1321
1344
  except ValueError as e:
1322
1345
  logger.warning(f"Certificate not found: {e}")
1346
+ if len(sender_certs) == 0:
1347
+ raise ValueError("Certificate not found")
1323
1348
 
1324
1349
  # get senders private key for signing
1325
1350
  try:
@@ -1333,7 +1358,10 @@ class Envelope:
1333
1358
 
1334
1359
  # sign the message first
1335
1360
  signature_builder = pkcs7.PKCS7SignatureBuilder().set_data(email)
1336
- signature_builder = signature_builder.add_signer(sender_cert, sender_key, hash_algorithm=hashes.SHA256())
1361
+ signature_builder = signature_builder.add_signer(sender_certs[0], sender_key, hash_algorithm=hashes.SHA256())
1362
+ # add the whole certificate chain
1363
+ for cert in sender_certs[1:]:
1364
+ signature_builder = signature_builder.add_certificate(cert)
1337
1365
  signed_email = signature_builder.sign(serialization.Encoding.SMIME, [pkcs7.PKCS7Options.DetachedSignature])
1338
1366
 
1339
1367
  # get recipent's certificate for encryption
@@ -1370,7 +1398,7 @@ class Envelope:
1370
1398
 
1371
1399
  def smime_encrypt_only(self, email, encrypt):
1372
1400
 
1373
- _, pkcs7, load_pem_x509_certificate, _, serialization = self._import_cryptoraphy_modules()
1401
+ _, pkcs7, _, load_pem_x509_certificate, _, serialization = self._import_cryptoraphy_modules()
1374
1402
 
1375
1403
  if self._cert:
1376
1404
  certificates = [self._cert]
@@ -1716,6 +1744,11 @@ class Envelope:
1716
1744
  passed *= bool(self._smtp.connect()) # check SMTP
1717
1745
  return passed
1718
1746
 
1747
+ def _check_auth(self):
1748
+ """ Experimental. """
1749
+ # NOTE Do not know how to map in API. Because .check() seems to be for sending and this one for receiving.
1750
+ return AuthResult.from_headers(self._headers)
1751
+
1719
1752
  def smtp_quit(self=None):
1720
1753
  """ Explicitly closes cached SMTP connections. Either class or instance can be called.
1721
1754
  Envelope.smtp_quit() → closing all
@@ -33,6 +33,8 @@ class SMTPHandler:
33
33
  d = locals()
34
34
  del d["self"]
35
35
  self.key = repr(d)
36
+ d["password"] = "********"
37
+ self.obfuscated_key = repr(d)
36
38
 
37
39
  def connect(self):
38
40
  if self.instance: # we received this instance as is so we suppose it is already connected
@@ -55,13 +57,13 @@ class SMTPHandler:
55
57
  smtp.login(self.user, self.password)
56
58
  except SMTPAuthenticationError as e:
57
59
  logger.error(
58
- f"SMTP authentication failed: {self.key}.\n{e}")
60
+ f"SMTP authentication failed: {self.obfuscated_key}.\n{e}")
59
61
  return False
60
62
  except SMTPException as e:
61
- logger.error(f"SMTP connection failed: {self.key}.\n{e}")
63
+ logger.error(f"SMTP connection failed: {self.obfuscated_key}.\n{e}")
62
64
  return False
63
65
  except (gaierror, ConnectionError):
64
- logger.error(f"SMTP connection refused: {self.key}.")
66
+ logger.error(f"SMTP connection refused: {self.obfuscated_key}.")
65
67
  return False
66
68
  return smtp
67
69
 
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "envelope"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "Insert a message and attachments and send e-mail / sign / encrypt contents by a single line."
9
9
  authors = ["Edvard Rejthar <edvard.rejthar@nic.cz>"]
10
10
  license = "LGPL-3.0-or-later"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes