Skip to content

Commit

Permalink
Merge pull request #221 from bityob/add-dkim-support
Browse files Browse the repository at this point in the history
  • Loading branch information
kootenpv authored Jan 14, 2022
2 parents ae89d97 + a107bf1 commit d2955d0
Show file tree
Hide file tree
Showing 11 changed files with 207 additions and 11 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ docs/_static/*
.coverage
.coverage.*
.coveralls.yml
.idea
6 changes: 6 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
language: python
matrix:
include:
- python: 3.10
env: TOX_ENV=py310
- python: 3.9
env: TOX_ENV=py39
- python: 3.8
env: TOX_ENV=py38
- python: 3.7
env: TOX_ENV=py37
- python: 3.6
env: TOX_ENV=py36
install:
- pip install tox
script:
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ In 2020, I personally prefer: using an [Application-Specific Password](https://s
|[Usability](#usability) | Shows some usage patterns for sending |
|[Recipients](#recipients) | How to send to multiple people, give an alias or send to self |
|[Magical contents](#magical-contents) | Really easy to send text, html, images and attachments |
|[Attaching files](#attaching-files) | How attach files to the email |
|[DKIM Support](#dkim-support) | Add DKIM signature to your emails with your private key |
|[Feedback](#feedback) | How to send me feedback |
|[Roadmap (and priorities)](#roadmap-and-priorities) | Yup |
|[Errors](#errors) | List of common errors for people dealing with sending emails |
Expand Down Expand Up @@ -210,7 +212,37 @@ Therefore, it is highly recommended setting the filename with extension manually

A real-world example would be if the attachment is retrieved from a different source than the disk (e.g. downloaded from the internet or [uploaded by a user in a web-application](https://docs.streamlit.io/en/stable/api.html#streamlit.file_uploader))

### DKIM Support

To send emails with dkim signature, you need to install the package with all related packages.
```
pip install yagmail[all]
# or
pip install yagmail[dkim]
```

Usage:
```python
from yagmail import SMTP
from yagmail.dkim import DKIM
from pathlib import Path

# load private key from file/secrets manager
private_key = Path("privkey.pem").read_bytes()

dkim_obj = DKIM(
domain=b"a.com",
selector=b"selector",
private_key=private_key,
include_headers=[b"To", b"From", b"Subject"],
# To include all default headers just pass None instead
# include_headers=None,
)

yag = SMTP(dkim=dkim_obj)

# all the rest is the same
```
### Feedback

I'll try to respond to issues within 24 hours at Github.....
Expand Down
6 changes: 3 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
with open('README.rst') as f:
LONG_DESCRIPTION = f.read()
MAJOR_VERSION = '0'
MINOR_VERSION = '14'
MICRO_VERSION = '260'
MINOR_VERSION = '15'
MICRO_VERSION = '0'
VERSION = "{}.{}.{}".format(MAJOR_VERSION, MINOR_VERSION, MICRO_VERSION)

setup(
Expand All @@ -17,7 +17,7 @@
author='Pascal van Kooten',
author_email='[email protected]',
license='MIT',
extras_require={"all": ["keyring"]},
extras_require={"all": ["keyring", "dkimpy"], "dkim": ["dkimpy"]},
install_requires=["premailer"],
keywords='email mime automatic html attachment',
entry_points={'console_scripts': ['yagmail = yagmail.__main__:main']},
Expand Down
1 change: 1 addition & 0 deletions tests/domainkey-dns.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkwMu7eqAx9WrL4lwio01L65D425hBs54Aw4HODsHQYiwQejKsZdj+kneLpm9Zdvm3U1FDD+SfkBWGJmlScoj5Kg0nYx0c0RVeowKetVrmTL7t7d01ag+QRnCBHN1E/B99rFpy47WtwAOuPuKZKIc40JvkCphxVj6GbJZsPjyA2YuhLDp0zVvNzQ61mbM5OC50unppH73maqQVh4f3kIm3Cfxbe8yw8hfVlmZomuSwv3HpZLgrF4ktktI2f3q18Wx4e4OOHaanv/b8VrXo6qIV6RLH5FSteyzFfs+qZbbaWmSDjYEoIHS/oZkaNQOZOkr2T12Rnu/lk/ubDErqaCLXQIDAQAB
27 changes: 27 additions & 0 deletions tests/privkey.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEAkwMu7eqAx9WrL4lwio01L65D425hBs54Aw4HODsHQYiwQejK
sZdj+kneLpm9Zdvm3U1FDD+SfkBWGJmlScoj5Kg0nYx0c0RVeowKetVrmTL7t7d0
1ag+QRnCBHN1E/B99rFpy47WtwAOuPuKZKIc40JvkCphxVj6GbJZsPjyA2YuhLDp
0zVvNzQ61mbM5OC50unppH73maqQVh4f3kIm3Cfxbe8yw8hfVlmZomuSwv3HpZLg
rF4ktktI2f3q18Wx4e4OOHaanv/b8VrXo6qIV6RLH5FSteyzFfs+qZbbaWmSDjYE
oIHS/oZkaNQOZOkr2T12Rnu/lk/ubDErqaCLXQIDAQABAoIBADWdWpcgB9lZXnYW
vLl66CO8fTvLfI077V7H1fA27t2CmS1gVdPQr4CPQf1iykUEnrykuoLOCIIMupl8
J2Cy3MY+ZfnzSGDlUftAaW4EuZoEkvKccHqfQh0B5NU0ukUMVxQJ/dhj/oB8/+GM
sxsiWEC1cPR10HRlj8ihV76H+9Mq+k9+/LrT8AU4qJHTZCwNvS/IESz67uutqtn2
EgYN70QPIgQLYDCLiH8D3d3bE/YfOBLMJNxWYDIFcUtDtRmvB8Qrx+dzhp1wOVj6
Ouwav5e+ZZu2LKkbRiENxjR9OrcHHcVdnuNYIfGnriPrksqljTurgamr+7zJo2Dg
dalgNAECgYEAxqBXSu+YyMuT24KY01KXYDB4iK0J9XbF/Zd9VKCG4sJe/ZQWqjSH
1IMb2yDQTNVic0NSQnQ6tu55UT+Avw0y4VsYsEdeyf2Y1wi+K7L2VLqny2ihjsNE
pN7kJO31NPc4rELsxzxU6nQK4EAAlZs2H5BZSmPxbe0+gB3x2B23dwECgYEAvXox
ESTKfn/XXTZOrS1Iv2zEonlCu5ERroAcFi8BKV3TpCOQfWDYUIflrksDkRAHjMl1
tyNmT/fPBLH9EL4CHevPOpUweHsG9LNyp3An5IpcOorzD3TTfEl+FSKZ1D4uUYeD
rOx7QVCSrOAtbLQlP5Oc61blY5JINxB1TaLKUF0CgYBdd+acJNPI6cPScEpqZ1tE
sIqIBqXBFPtmsnsP79qJqt34hk+EGOQyZOAe5fofrep+QxfancdjfiUozrFPNm7T
DYM4sN0yQFxEFKEo/zZb+NotJjegbtNGonzJxBC3s/6/UV8LAqETEzhq/rNHs5ps
kAj0sMNT72iR8YV1JcbIAQKBgGzyfJIh+HkCIyBKoLR8zE6dSPcvCEr3YBZZPU0Y
G+/gLlg7xtIAxICRk2RDZ7qaX+z4zcHPDf4/O/60JRHiXy87Lr29mNA91UMQh4V1
PMrxL5TN3nJtt0jIrUGT0qWyV0mzxOfCViC5Jo1WnWfasWw8AUdkgKNfMjzPLtPE
HdZVAoGAMw4Ffpdy1UPzB/nSJYzhGyXpHOWfrkfwyzTFpnKw0BeGA1AYWNxVIKHY
zqVtTBTSZcLJc4+4oVYuE7Qpvyx3fIuyDoVJXgUohCk8z5HtshevGeYuNQd2Vj94
TigapgfF4hY6cBjA4hNIUlgWF1scX7aNEvq1EDGcy+SwjUiR+J0=
-----END RSA PRIVATE KEY-----
84 changes: 84 additions & 0 deletions tests/test_dkim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import base64
import logging
from pathlib import Path
from unittest.mock import Mock

import dkim


def get_txt_from_test_file(*args, **kwargs):
dns_data_file = Path(__file__).parent / "domainkey-dns.txt"

return Path(dns_data_file).read_bytes()


def _test_email_with_dkim(include_headers):
from yagmail import SMTP
from yagmail.dkim import DKIM

private_key_path = Path(__file__).parent / "privkey.pem"

private_key = private_key_path.read_bytes()

dkim_obj = DKIM(
domain=b"a.com",
selector=b"selector",
private_key=private_key,
include_headers=include_headers,
)

yag = SMTP(
user="[email protected]",
host="smtp.blabla.com",
port=25,
dkim=dkim_obj,
)

yag.login = Mock()

to = "[email protected]"

recipients, msg_bytes = yag.send(
to=to,
subject="hello from tests",
contents="important message",
preview_only=True
)

msg_string = msg_bytes.decode("utf8")

assert recipients == [to]
assert "Subject: hello from tests" in msg_string
text_b64 = base64.b64encode(b"important message").decode("utf8")
assert text_b64 in msg_string

dkim_string1 = "DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=a.com; [email protected];\n " \
"q=dns/txt; s=selector; t="
assert dkim_string1 in msg_string

l = logging.getLogger()
l.setLevel(level=logging.DEBUG)
logging.basicConfig(level=logging.DEBUG)

assert dkim.verify(
message=msg_string.encode("utf8"),
logger=l,
dnsfunc=get_txt_from_test_file
)

return msg_string


def test_email_with_dkim():
msg_string = _test_email_with_dkim(include_headers=[b"To", b"From", b"Subject"])

dkim_string2 = "h=to : from : subject;"
assert dkim_string2 in msg_string


def test_dkim_without_including_headers():
msg_string = _test_email_with_dkim(include_headers=None)

dkim_string_headers = "h=content-type : mime-version :\n date : subject : from : to : message-id : from;\n"
assert dkim_string_headers in msg_string

2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tox]
envlist = py37,py38
envlist = py36,py37,py38,py39,py310

[testenv]
# If you add a new dep here you probably need to add it in setup.py as well
Expand Down
35 changes: 35 additions & 0 deletions yagmail/dkim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from email.mime.base import MIMEBase
from typing import NamedTuple

try:
import dkim
except ImportError:
dkim = None
pass


class DKIM(NamedTuple):
domain: bytes
private_key: bytes
include_headers: list
selector: bytes


def add_dkim_sig_to_message(msg: MIMEBase, dkim_obj: DKIM) -> None:
if dkim is None:
raise RuntimeError("dkim package not installed")

# Based on example from:
# https://github.com/russellballestrini/russell.ballestrini.net/blob/master/content/
# 2018-06-04-quickstart-to-dkim-sign-email-with-python.rst
sig = dkim.sign(
message=msg.as_bytes(),
selector=dkim_obj.selector,
domain=dkim_obj.domain,
privkey=dkim_obj.private_key,
include_headers=dkim_obj.include_headers,
)
# add the dkim signature to the email message headers.
# decode the signature back to string_type because later on
# the call to msg.as_string() performs it's own bytes encoding...
msg["DKIM-Signature"] = sig[len("DKIM-Signature: "):].decode()
6 changes: 6 additions & 0 deletions yagmail/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from email.mime.text import MIMEText
from email.utils import formatdate

from yagmail.dkim import add_dkim_sig_to_message
from yagmail.headers import add_message_id
from yagmail.headers import add_recipients_headers
from yagmail.headers import add_subject
Expand Down Expand Up @@ -49,6 +50,7 @@ def prepare_message(
prettify_html=True,
message_id=None,
group_messages=True,
dkim=None,
):
# check if closed!!!!!! XXX
"""Prepare a MIME message"""
Expand Down Expand Up @@ -145,6 +147,10 @@ def prepare_message(
msg_related.get_payload()[0] = MIMEText(htmlstr, "html", _charset=encoding)
msg_alternative.attach(MIMEText("\n".join(altstr), _charset=encoding))
msg_alternative.attach(msg_related)

if dkim is not None:
add_dkim_sig_to_message(msg, dkim)

return msg


Expand Down
18 changes: 11 additions & 7 deletions yagmail/sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def __init__(
encoding="utf-8",
oauth2_file=None,
soft_email_validation=True,
dkim=None,
**kwargs
):
self.log = get_logger()
Expand Down Expand Up @@ -62,6 +63,7 @@ def __init__(
self.num_mail_sent = 0
self.oauth2_file = oauth2_file
self.credentials = password if oauth2_file is None else oauth2_info
self.dkim = dkim

def __enter__(self):
return self
Expand Down Expand Up @@ -129,11 +131,12 @@ def prepare_send(
prettify_html,
message_id,
group_messages,
self.dkim,
)

recipients = addresses["recipients"]
msg_string = msg.as_string()
return recipients, msg_string
msg_bytes = msg.as_bytes()
return recipients, msg_bytes

def send(
self,
Expand All @@ -151,7 +154,7 @@ def send(
):
""" Use this to send an email with gmail"""
self.login()
recipients, msg_string = self.prepare_send(
recipients, msg_bytes = self.prepare_send(
to,
subject,
contents,
Expand All @@ -164,14 +167,15 @@ def send(
group_messages,
)
if preview_only:
return (recipients, msg_string)
return self._attempt_send(recipients, msg_string)
return recipients, msg_bytes

def _attempt_send(self, recipients, msg_string):
return self._attempt_send(recipients, msg_bytes)

def _attempt_send(self, recipients, msg_bytes):
attempts = 0
while attempts < 3:
try:
result = self.smtp.sendmail(self.user, recipients, msg_string)
result = self.smtp.sendmail(self.user, recipients, msg_bytes)
self.log.info("Message sent to %s", recipients)
self.num_mail_sent += 1
return result
Expand Down

0 comments on commit d2955d0

Please sign in to comment.