├── .coveragerc ├── .gitignore ├── .pyup.yml ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── README.rst ├── conftest.py ├── deploy.py ├── docs ├── Makefile ├── _static │ └── icon.png ├── api.rst ├── conf.py ├── index.rst ├── make.bat ├── setup.rst └── usage.rst ├── setup.cfg ├── setup.py ├── tests ├── all_test.py ├── domainkey-dns.txt ├── privkey.pem └── test_dkim.py ├── tox.ini └── yagmail ├── __init__.py ├── __main__.py ├── compat.py ├── dkim.py ├── error.py ├── example.html ├── headers.py ├── log.py ├── message.py ├── oauth2.py ├── password.py ├── sender.py ├── sky.jpg ├── utils.py └── validate.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | def __repr__ 5 | raise AssertionError 6 | raise NotImplementedError 7 | if __name__ == .__main__.: -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *#* 3 | *.DS_STORE 4 | *.log 5 | *Data.fs* 6 | *flymake* 7 | dist/* 8 | *egg* 9 | urllist* 10 | build/ 11 | __pycache__/ 12 | /.Python 13 | /bin/ 14 | docs/_build/ 15 | docs/_static/* 16 | !docs/_static/icon.png 17 | /include/ 18 | /lib/ 19 | /pip-selfcheck.json 20 | .tox/ 21 | .cache 22 | .coverage 23 | .coverage.* 24 | .coveralls.yml 25 | .idea 26 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | # autogenerated pyup.io config file 2 | # see https://pyup.io/docs/configuration/ for all available options 3 | 4 | schedule: every week 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | matrix: 3 | include: 4 | - python: 3.10 5 | env: TOX_ENV=py310 6 | - python: 3.9 7 | env: TOX_ENV=py39 8 | - python: 3.8 9 | env: TOX_ENV=py38 10 | - python: 3.7 11 | env: TOX_ENV=py37 12 | - python: 3.6 13 | env: TOX_ENV=py36 14 | install: 15 | - pip install tox 16 | script: 17 | - tox -e $TOX_ENV 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include README.rst 3 | include LICENSE 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 |

5 | 6 | # yagmail -- Yet Another GMAIL/SMTP client 7 | 8 | [![Join the chat at https://gitter.im/kootenpv/yagmail](https://badges.gitter.im/kootenpv/yagmail.svg)](https://gitter.im/kootenpv/yagmail?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 9 | [![PyPI](https://img.shields.io/pypi/v/yagmail.svg?style=flat-square)](https://pypi.python.org/pypi/yagmail/) 10 | [![PyPI](https://img.shields.io/pypi/pyversions/yagmail.svg?style=flat-square)](https://pypi.python.org/pypi/yagmail/) 11 | 12 | *For the asynchronous asyncio version, look here*: https://github.com/kootenpv/aioyagmail 13 | 14 | The goal here is to make it as simple and painless as possible to send emails. 15 | 16 | In the end, your code will look something like this: 17 | 18 | ```python 19 | import yagmail 20 | yag = yagmail.SMTP('mygmailusername', 'mygmailpassword') 21 | contents = ['This is the body, and here is just text http://somedomain/image.png', 22 | 'You can find an audio file attached.', '/local/path/song.mp3'] 23 | yag.send('to@someone.com', 'subject', contents) 24 | ``` 25 | 26 | In 2020, I personally prefer: using an [Application-Specific Password](https://support.google.com/accounts/answer/185833) 27 | 28 | ### Table of Contents 29 | 30 | |Section|Explanation| 31 | |---------------------------------------------------------------|---------------------------------------------------------------------| 32 | |[Install](#install) | Find the instructions on how to install yagmail here | 33 | |[Start a connection](#start-a-connection) | Get started | 34 | |[Usability](#usability) | Shows some usage patterns for sending | 35 | |[Recipients](#recipients) | How to send to multiple people, give an alias or send to self | 36 | |[Magical contents](#magical-contents) | Really easy to send text, html, images and attachments | 37 | |[Attaching files](#attaching-files) | How attach files to the email | 38 | |[DKIM Support](#dkim-support) | Add DKIM signature to your emails with your private key | 39 | |[Feedback](#feedback) | How to send me feedback | 40 | |[Roadmap (and priorities)](#roadmap-and-priorities) | Yup | 41 | |[Errors](#errors) | List of common errors for people dealing with sending emails | 42 | 43 | 44 | ### Install 45 | 46 | For Python 2.x and Python 3.x respectively: 47 | 48 | ```python 49 | pip install yagmail[all] 50 | pip3 install yagmail[all] 51 | 52 | ``` 53 | 54 | As a side note, `yagmail` can now also be used to send emails from the command line. 55 | 56 | ### Start a connection 57 | 58 | ```python 59 | yag = yagmail.SMTP('mygmailusername', 'mygmailpassword') 60 | ``` 61 | 62 | Note that this connection is reusable, closable and when it leaves scope it will **clean up after itself in CPython**. 63 | 64 | As [tilgovi](https://github.com/tilgovi) points out in [#39](https://github.com/kootenpv/yagmail/issues/39), SMTP does not automatically close in **PyPy**. The context manager `with` should be used in that case. 65 | 66 | 67 | ### Usability 68 | 69 | Defining some variables: 70 | 71 | ```python 72 | to = 'santa@someone.com' 73 | to2 = 'easterbunny@someone.com' 74 | to3 = 'sky@pip-package.com' 75 | subject = 'This is obviously the subject' 76 | body = 'This is obviously the body' 77 | html = 'Click me!' 78 | img = '/local/file/bunny.png' 79 | ``` 80 | 81 | All variables are optional, and know that not even `to` is required (you'll send an email to yourself): 82 | 83 | ```python 84 | yag.send(to = to, subject = subject, contents = body) 85 | yag.send(to = to, subject = subject, contents = [body, html, img]) 86 | yag.send(contents = [body, img]) 87 | ``` 88 | 89 | Furthermore, if you do not want to be explicit, you can do the following: 90 | 91 | ```python 92 | yag.send(to, subject, [body, img]) 93 | ``` 94 | 95 | ### Recipients 96 | 97 | It is also possible to send to a group of people by providing a list of email strings rather than a single string: 98 | 99 | ```python 100 | yag.send(to = to) 101 | yag.send(to = [to, to2]) # List or tuples for emailadresses *without* aliases 102 | yag.send(to = {to : 'Alias1'}) # Dictionary for emailaddress *with* aliases 103 | yag.send(to = {to : 'Alias1', to2 : 'Alias2'} 104 | ``` 105 | 106 | Giving no `to` argument will send an email to yourself. In that sense, `yagmail.SMTP().send()` can already send an email. 107 | Be aware that if no explicit `to = ...` is used, the first argument will be used to send to. Can be avoided like: 108 | 109 | ```python 110 | yag.send(subject = 'to self', contents = 'hi!') 111 | ``` 112 | 113 | Note that by default all email addresses are conservatively validated using `soft_email_validation==True` (default). 114 | 115 | ### Oauth2 116 | 117 | It is even safer to use Oauth2 for authentication, as you can revoke the rights of tokens. 118 | 119 | [This](http://blog.macuyiko.com/post/2016/how-to-send-html-mails-with-oauth2-and-gmail-in-python.html) is one of the best sources, upon which the oauth2 code is heavily based. 120 | 121 | The code: 122 | 123 | ```python 124 | yag = yagmail.SMTP("user@gmail.com", oauth2_file="~/oauth2_creds.json") 125 | yag.send(subject="Great!") 126 | ``` 127 | 128 | It will prompt for a `google_client_id` and a `google_client_secret`, when the file cannot be found. These variables can be obtained following [the previous link](http://blog.macuyiko.com/post/2016/how-to-send-html-mails-with-oauth2-and-gmail-in-python.html). 129 | 130 | After you provide them, a link will be shown in the terminal that you should followed to obtain a `google_refresh_token`. Paste this again, and you're set up! 131 | 132 | Note that people who obtain the file can send emails, but nothing else. As soon as you notice, you can simply disable the token. 133 | 134 | #### Preventing OAuth authorization from expiring after 7 days 135 | 136 | Your Google Cloud Platform project's OAuth consent screen must be in **"In production" publishing status** before authorizing to not have the authorization expire after 7 days. See status at https://console.cloud.google.com/apis/credentials/consent 137 | 138 | Your OAuth **client ID must be of type "Desktop"**. Check at https://console.cloud.google.com/apis/credentials 139 | 140 | ### Magical `contents` 141 | 142 | The `contents` argument will be smartly guessed. It can be passed a string (which will be turned into a list); or a list. For each object in the list: 143 | 144 | - If it is a dictionary it will assume the key is the content, and the value is an alias (only for images currently!) 145 | e.g. {'/path/to/image.png' : 'MyPicture'} 146 | - It will try to see if the content (string) can be read as a file locally, 147 | e.g. '/path/to/image.png' 148 | - if impossible, it will check if the string is valid html 149 | e.g. `

This is a big title

` 150 | - if not, it must be text. 151 | e.g. 'Hi Dorika!' 152 | 153 | Note that local files can be html (inline); everything else will be attached. 154 | 155 | Local files require to have an extension for their content type to be inferred. 156 | 157 | As of version 0.4.94, `raw` and `inline` have been added. 158 | 159 | - `raw` ensures a string will not receive any "magic" (inlining, html, attaching) 160 | - `inline` will make an image appear in the text. 161 | 162 | ### Attaching Files 163 | There are multiple ways to attach files in the `attachments` parameter (in addition to magical `contents` parameter). 164 | 1. One can pass a list of paths i.e. 165 | ```python 166 | yag.send(to=recipients, 167 | subject=email_subject, 168 | contents=contents, 169 | attachments=['path/to/attachment1.png', 'path/to/attachment2.pdf', 'path/to/attachment3.zip'] 170 | ) 171 | ``` 172 | 2. One can pass an instance of [`io.IOBase`](https://docs.python.org/3/library/io.html#io.IOBase). 173 | ```python 174 | with open('path/to/attachment', 'rb') as f: 175 | yag.send(to=recipients, 176 | subject=email_subject, 177 | contents=contents, 178 | attachments=f 179 | ) 180 | ``` 181 | In this example `f` is an instance of `_io.BufferedReader` a subclass of the abstract class `io.IOBase`. 182 | 183 | `f` has in this example the attribute `.name`, which is used by yagmail as filename as well as to detect the correct MIME-type. 184 | Not all `io.IOBase` instances have the `.name` attribute in which case yagmail names the attachments `attachment1`, `attachment2`, ... without a file extension! 185 | Therefore, it is highly recommended setting the filename with extension manually e.g. `f.name = 'my_document.pdf'` 186 | 187 | 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)) 188 | 189 | ### DKIM Support 190 | 191 | To send emails with dkim signature, you need to install the package with all related packages. 192 | ``` 193 | pip install yagmail[all] 194 | # or 195 | pip install yagmail[dkim] 196 | ``` 197 | 198 | Usage: 199 | ```python 200 | from yagmail import SMTP 201 | from yagmail.dkim import DKIM 202 | from pathlib import Path 203 | 204 | # load private key from file/secrets manager 205 | private_key = Path("privkey.pem").read_bytes() 206 | 207 | dkim_obj = DKIM( 208 | domain=b"a.com", 209 | selector=b"selector", 210 | private_key=private_key, 211 | include_headers=[b"To", b"From", b"Subject"], 212 | # To include all default headers just pass None instead 213 | # include_headers=None, 214 | ) 215 | 216 | yag = SMTP(dkim=dkim_obj) 217 | 218 | # all the rest is the same 219 | ``` 220 | ### Feedback 221 | 222 | I'll try to respond to issues within 24 hours at Github..... 223 | 224 | And please send me a line of feedback with `SMTP().feedback('Great job!')` :-) 225 | 226 | ### Roadmap (and priorities) 227 | 228 | - ~~Added possibility of Image~~ 229 | - ~~Optional SMTP arguments should go with \**kwargs to my SMTP~~ 230 | - ~~CC/BCC (high)~~ 231 | - ~~Custom names (high)~~ 232 | - ~~Allow send to return a preview rather than to actually send~~ 233 | - ~~Just use attachments in "contents", being smart guessed (high, complex)~~ 234 | - ~~Attachments (contents) in a list so they actually define the order (medium)~~ 235 | - ~~Use lxml to see if it can parse the html (low)~~ 236 | - ~~Added tests (high)~~ 237 | - ~~Allow caching of content (low)~~ 238 | - ~~Extra other types (low)~~ (for example, mp3 also works, let me know if something does not work) 239 | - ~~Probably a naming issue with content type/default type~~ 240 | - ~~Choose inline or not somehow (high)~~ 241 | - ~~Make lxml module optional magic (high)~~ 242 | - ~~Provide automatic fallback for complex content(medium)~~ (should work) 243 | - ~~`yagmail` as a command on CLI upon install~~ 244 | - ~~Added `feedback` function on SMTP to be able to send me feedback directly :-)~~ 245 | - ~~Added the option to validate emailaddresses...~~ 246 | - ~~however, I'm unhappy with the error handling/logging of wrong emails~~ 247 | - ~~Logging count & mail capability (very low)~~ 248 | - ~~Add documentation to exception classes (low)~~ 249 | - ~~add `raw` and `inline`~~ 250 | - ~~oauth2~~ 251 | - ~~Travis CI integration ~~ 252 | - ~~ Add documentation to all functions (high, halfway) ~~ 253 | - Prepare for official 1.0 254 | - Go over documentation again (medium) 255 | - Allow `.yagmail` file to contain more parameters (medium) 256 | - Add option to shrink images (low) 257 | 258 | ### Errors 259 | 260 | - [`smtplib.SMTPException: SMTP AUTH extension not supported by server`](http://stackoverflow.com/questions/10147455/trying-to-send-email-gmail-as-mail-provider-using-python) 261 | 262 | - [`SMTPAuthenticationError: Application-specific password required`](https://support.google.com/accounts/answer/185833) 263 | 264 | - **YagAddressError**: This means that the address was given in an invalid format. Note that `From` can either be a string, or a dictionary where the key is an `email`, and the value is an `alias` {'sample@gmail.com': 'Sam'}. In the case of 'to', it can either be a string (`email`), a list of emails (email addresses without aliases) or a dictionary where keys are the email addresses and the values indicate the aliases. 265 | 266 | - **YagInvalidEmailAddress**: Note that this will only filter out syntax mistakes in emailaddresses. If a human would think it is probably a valid email, it will most likely pass. However, it could still very well be that the actual emailaddress has simply not be claimed by anyone (so then this function fails to devalidate). 267 | 268 | - Click to enable the email for being used externally https://www.google.com/settings/security/lesssecureapps 269 | 270 | - Make sure you have a working internet connection 271 | 272 | - If you get an `ImportError` try to install with `sudo`, see issue #13 273 | 274 | ### Donate 275 | 276 | If you like `yagmail`, feel free (no pun intended) to donate any amount you'd like :-) 277 | 278 | [![PayPal](https://www.paypalobjects.com/en_US/i/btn/btn_donate_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=Y7QCCEPGC6R5E) 279 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | yagmail - Yet another GMAIL / SMTP client 2 | ========================================= 3 | `yagmail` is a GMAIL/SMTP client that aims to 4 | make it as simple as possible to send emails. 5 | 6 | Sending an Email is as simple: 7 | 8 | .. code-block:: python 9 | 10 | import yagmail 11 | yag = yagmail.SMTP() 12 | contents = [ 13 | "This is the body, and here is just text http://somedomain/image.png", 14 | "You can find an audio file attached.", '/local/path/to/song.mp3' 15 | ] 16 | yag.send('to@someone.com', 'subject', contents) 17 | 18 | # Alternatively, with a simple one-liner: 19 | yagmail.SMTP('mygmailusername').send('to@someone.com', 'subject', contents) 20 | 21 | Note that yagmail will read the password securely from 22 | your keyring, see the section on 23 | `Username and Password in the repository's README 24 | `_ 25 | for further details. If you do not want this, you can 26 | initialize ``yagmail.SMTP`` like this: 27 | 28 | .. code-block:: python 29 | 30 | yag = yagmail.SMTP('mygmailusername', 'mygmailpassword') 31 | 32 | but honestly, do you want to have your 33 | password written in your script? 34 | 35 | For further documentation and examples, 36 | please go to https://github.com/kootenpv/yagmail. 37 | 38 | The full documentation is available at 39 | http://yagmail.readthedocs.io/en/latest/. 40 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kootenpv/yagmail/0591606f3eb87502a6a16c42c775f66380dd72c1/conftest.py -------------------------------------------------------------------------------- /deploy.py: -------------------------------------------------------------------------------- 1 | """ File unrelated to the package, except for convenience in deploying """ 2 | import re 3 | import sh 4 | import os 5 | 6 | commit_count = sh.git("rev-list", ["--all"]).count("\n") 7 | 8 | with open("setup.py") as f: 9 | setup = f.read() 10 | 11 | setup = re.sub("MICRO_VERSION = '[0-9]+'", "MICRO_VERSION = '{}'".format(commit_count), setup) 12 | 13 | major = re.search("MAJOR_VERSION = '([0-9]+)'", setup).groups()[0] 14 | minor = re.search("MINOR_VERSION = '([0-9]+)'", setup).groups()[0] 15 | micro = re.search("MICRO_VERSION = '([0-9]+)'", setup).groups()[0] 16 | version = "{}.{}.{}".format(major, minor, micro) 17 | 18 | with open("setup.py", "w") as f: 19 | f.write(setup) 20 | 21 | with open("yagmail/__init__.py") as f: 22 | init = f.read() 23 | 24 | with open("yagmail/__init__.py", "w") as f: 25 | f.write(re.sub('__version__ = "[0-9.]+"', '__version__ = "{}"'.format(version), init)) 26 | 27 | py_version = "python" 28 | os.system("rm -rf dist/") 29 | os.system("{} setup.py sdist bdist_wheel".format(py_version)) 30 | os.system("twine upload dist/*") 31 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = yagmail 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/_static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kootenpv/yagmail/0591606f3eb87502a6a16c42c775f66380dd72c1/docs/_static/icon.png -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | This page displays a full reference of `yagmail`\'s API. 4 | 5 | 6 | Authentication 7 | -------------- 8 | 9 | .. autofunction:: yagmail.register 10 | 11 | Another way of authenticating is by passing an ``oauth2_file`` to 12 | :class:`yagmail.SMTP`, which is among the safest methods of authentication. 13 | Please see the `OAuth2 section `_ 14 | of the `README `_ 15 | for further details. 16 | 17 | It is also possible to simply pass the password to :class:`yagmail.SMTP`. 18 | If no password is given, yagmail will prompt the user for a password and 19 | then store the result in the keyring. 20 | 21 | 22 | SMTP Client 23 | ----------- 24 | .. autoclass:: yagmail.SMTP 25 | :members: 26 | 27 | 28 | E-Mail Contents 29 | --------------- 30 | .. autoclass:: yagmail.raw 31 | 32 | .. autoclass:: yagmail.inline 33 | 34 | 35 | Exceptions 36 | ---------- 37 | .. automodule:: yagmail.error 38 | :members: 39 | 40 | 41 | Utilities 42 | ----------------- 43 | .. autofunction:: yagmail.validate.validate_email_with_regex 44 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # yagmail documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Sep 19 19:40:28 2017. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | sys.path.insert(0, os.path.abspath('..')) 22 | 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.autodoc', 34 | 'sphinx.ext.intersphinx', 35 | 'sphinx.ext.viewcode'] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix(es) of source filenames. 41 | # You can specify multiple suffix as a list of string: 42 | # 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = '.rst' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = u'yagmail' 51 | copyright = u'2017, kootenpv' 52 | author = u'kootenpv' 53 | 54 | # The version info for the project you're documenting, acts as replacement for 55 | # |version| and |release|, also used in various other places throughout the 56 | # built documents. 57 | # 58 | # The short X.Y version. 59 | version = u'0.10.189' 60 | # The full version, including alpha/beta/rc tags. 61 | release = u'0.10.189' 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = None 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | # This patterns also effect to html_static_path and html_extra_path 73 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | pygments_style = 'sphinx' 77 | 78 | # If true, `todo` and `todoList` produce output, else they produce nothing. 79 | todo_include_todos = False 80 | 81 | 82 | # -- Options for HTML output ---------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = 'alabaster' 88 | 89 | # Theme options are theme-specific and customize the look and feel of a theme 90 | # further. For a list of options available for each theme, see the 91 | # documentation. 92 | # 93 | html_theme_options = { 94 | 'logo': "icon.png", 95 | 'logo_name': True, 96 | 'description': ("yagmail makes sending emails very " 97 | "easy by doing all the magic for you"), 98 | 99 | 'github_user': "kootenpv", 100 | 'github_repo': "yagmail", 101 | 'github_button': True, 102 | 'github_type': 'star' 103 | } 104 | 105 | # Add any paths that contain custom static files (such as style sheets) here, 106 | # relative to this directory. They are copied after the builtin static files, 107 | # so a file named "default.css" will overwrite the builtin "default.css". 108 | html_static_path = ['_static'] 109 | 110 | # Custom sidebar templates, must be a dictionary that maps document names 111 | # to template names. 112 | # 113 | # This is required for the alabaster theme 114 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 115 | html_sidebars = { 116 | '**': [ 117 | 'about.html', 118 | 'navigation.html', 119 | 'relations.html', # needs 'show_related': True theme option to display 120 | 'searchbox.html', 121 | 'donate.html', 122 | ] 123 | } 124 | 125 | 126 | # -- Options for HTMLHelp output ------------------------------------------ 127 | 128 | # Output file base name for HTML help builder. 129 | htmlhelp_basename = 'yagmaildoc' 130 | 131 | 132 | # -- Options for LaTeX output --------------------------------------------- 133 | 134 | latex_elements = { 135 | # The paper size ('letterpaper' or 'a4paper'). 136 | # 137 | # 'papersize': 'letterpaper', 138 | 139 | # The font size ('10pt', '11pt' or '12pt'). 140 | # 141 | # 'pointsize': '10pt', 142 | 143 | # Additional stuff for the LaTeX preamble. 144 | # 145 | # 'preamble': '', 146 | 147 | # Latex figure (float) alignment 148 | # 149 | # 'figure_align': 'htbp', 150 | } 151 | 152 | # Grouping the document tree into LaTeX files. List of tuples 153 | # (source start file, target name, title, 154 | # author, documentclass [howto, manual, or own class]). 155 | latex_documents = [ 156 | (master_doc, 'yagmail.tex', u'yagmail Documentation', 157 | u'kootenpv', 'manual'), 158 | ] 159 | 160 | 161 | # -- Options for manual page output --------------------------------------- 162 | 163 | # One entry per manual page. List of tuples 164 | # (source start file, name, description, authors, manual section). 165 | man_pages = [ 166 | (master_doc, 'yagmail', u'yagmail Documentation', 167 | [author], 1) 168 | ] 169 | 170 | 171 | # -- Options for Texinfo output ------------------------------------------- 172 | 173 | # Grouping the document tree into Texinfo files. List of tuples 174 | # (source start file, target name, title, author, 175 | # dir menu entry, description, category) 176 | texinfo_documents = [ 177 | (master_doc, 'yagmail', u'yagmail Documentation', 178 | author, 'yagmail', 'One line description of project.', 179 | 'Miscellaneous'), 180 | ] 181 | 182 | 183 | # Example configuration for intersphinx: refer to the Python standard library. 184 | intersphinx_mapping = { 185 | "python": ('https://docs.python.org/3', None) 186 | } 187 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. yagmail documentation master file, created by 2 | sphinx-quickstart on Tue Sep 19 19:40:28 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | yagmail 7 | ======= 8 | `yagmail` is a GMAIL/SMTP client that aims to 9 | make it as simple as possible to send emails. 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | api 15 | setup 16 | usage 17 | 18 | 19 | 20 | Indices and tables 21 | ================== 22 | 23 | * :ref:`genindex` 24 | * :ref:`modindex` 25 | * :ref:`search` 26 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=yagmail 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/setup.rst: -------------------------------------------------------------------------------- 1 | Setup 2 | ===== 3 | This page shows you how to install ``yagmail`` and 4 | how to set it up to use your system keyring service. 5 | 6 | 7 | Installing from PyPI 8 | -------------------- 9 | The usual way of installing ``yagmail`` is through PyPI. 10 | It is recommended to install it together with the ``keyring`` 11 | library, by running the following (for Python 2.x and 3.x respectively):: 12 | 13 | pip install yagmail[all] 14 | pip3 install yagmail[all] 15 | 16 | If installing ``yagmail`` with ``keyring`` causes issues, 17 | omit the ``[all]`` to install it without. 18 | 19 | 20 | Installing from GitHub 21 | ---------------------- 22 | If you're not scared of things occasionally breaking, you can also 23 | install directly from the GitHub `repository `_. 24 | You can do this by running the following (for Python 2.x and 3.x respectively):: 25 | 26 | pip install -e git+https://github.com/kootenpv/yagmail#egg=yagmail[all] 27 | pip3 install -e git+https://github.com/kootenpv/yagmail#egg=yagmail[all] 28 | 29 | Just like with the PyPI installation method, if installing with ``keyring`` 30 | causes issues, simply omit the ``[all]`` to install ``yagmail`` without it. 31 | 32 | .. _configuring_credentials: 33 | 34 | Configuring Credentials 35 | ----------------------- 36 | While it's possible to put the username and password for your 37 | E-Mail address into your script, ``yagmail`` enables you to omit both. 38 | Quoting from ``keyring``\s `README `_:: 39 | 40 | What is Python keyring lib? 41 | 42 | The Python keyring lib provides a easy way to access the system 43 | keyring service from python. It can be used in any 44 | application that needs safe password storage. 45 | 46 | If this sparked your interest, set up a Python interpreter and run 47 | the following to register your GMail credentials with ``yagmail``: 48 | 49 | .. code-block:: python 50 | 51 | import yagmail 52 | yagmail.register('mygmailusername', 'mygmailpassword') 53 | 54 | (this is just a wrapper for ``keyring.set_password('yagmail', 'mygmailusername', 'mygmailpassword')``) 55 | Now, instantiating :class:`yagmail.SMTP` is as easy as doing: 56 | 57 | .. code-block:: python 58 | 59 | yag = yagmail.SMTP('mygmailusername') 60 | 61 | If you want to also omit your username, you can create a ``.yagmail`` 62 | file in your home folder, containing just your username. Then, you can 63 | instantiate the SMTP client without passing any arguments. 64 | 65 | 66 | Using OAuth2 67 | ------------ 68 | Another fairly safe method for authenticating using OAuth2, since 69 | you can revoke the rights of tokens. In order to use OAuth2, pass 70 | the location of the credentials file to :class:`yagmail.SMTP`: 71 | 72 | .. code-block:: python 73 | 74 | yag = yagmail.SMTP('user@gmail.com', oauth2_file='~/oauth2_creds.json') 75 | yag.send(subject="Great!") 76 | 77 | If the file could not be found, then it will prompt for a 78 | ``google_client_id`` and ``google_client_secret``. You can obtain these 79 | on `this OAauth2 Guide `_, 80 | upon which the OAauth2 code of ``yagmail`` is heavily based. 81 | After you have provided these, a link will be shown in the terminal that 82 | you should follow to obtain a ``google_refresh_token``. 83 | Paste this again, and you're set up! 84 | 85 | If somebody obtains the file, they can send E-Mails, but nothing else. 86 | As soon as you notice, you can simply disable the token. -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | This document aims to show how to use ``yagmail`` in your programs. 4 | Most of what is shown here is also available to see in the 5 | `README `_, some content may be 6 | duplicated for completeness. 7 | 8 | 9 | Start a Connection 10 | ------------------ 11 | As mentioned in :ref:`configuring_credentials`, there 12 | are three ways to initialize a connection by instantiating 13 | :class:`yagmail.SMTP`: 14 | 15 | 1. **With Username and Password**: 16 | e.g. ``yagmail.SMTP('mygmailusername', 'mygmailpassword')`` 17 | This method is not recommended, since you would be storing 18 | the full credentials to your account in your script in plain text. 19 | A better alternative is using ``keyring``, as described in the 20 | following section: 21 | 22 | 2. **With Username and keyring**: 23 | After registering a ``keyring`` entry for ``yagmail``, you can 24 | instantiate the client by simply passing your username, e.g. 25 | ``yagmail.SMTP('mygmailusername')``. 26 | 27 | 3. **With keyring and .yagmail**: 28 | As explained in the `Setup` documentation, you can also 29 | omit the username if you have a ``.yagmail`` file in your 30 | home folder, containing just your GMail username. This way, 31 | you can initialize :class:`yagmail.SMTP` without any arguments. 32 | 33 | 4. **With OAuth2**: 34 | This is probably the safest method of authentication, as you 35 | can revoke the rights of tokens. To initialize with OAuth2 36 | credentials (after obtaining these as shown in `Setup`), 37 | simply pass an ``oauth2_file`` to :class:`yagmail.SMTP`, 38 | for example ``yagmail.SMTP('user@gmail.com', oauth2_file='~/oauth2_creds.json')``. 39 | 40 | 41 | Closing and reusing the Connection 42 | ---------------------------------- 43 | By default, :class:`yagmail.SMTP` will clean up after itself 44 | **in CPython**. This is an implementation detail of CPython and as such 45 | may not work in other implementations such as PyPy (reported in 46 | `issue #39 `_). In those 47 | cases, you can use :class:`yagmail.SMTP` with ``with`` instead. 48 | 49 | Alternatively, you can close and re-use the connection with 50 | :meth:`yagmail.SMTP.close` and :meth:`yagmail.SMTP.login` (or 51 | :meth:`yagmail.SMTP.oauth2_file` if you are using OAuth2). 52 | 53 | 54 | Sending E-Mails 55 | --------------- 56 | :meth:`yagmail.SMTP.send` is a fairly versatile method that allows 57 | you to adjust more or less anything about your Mail. 58 | First of all, all parameters for :meth:`yagmail.SMTP.send` are optional. 59 | If you omit the recipient (specified with ``to``), you will send an 60 | E-Mail to yourself. 61 | 62 | Since the use of the (keyword) arguments of :meth:`yagmail.SMTP.send` 63 | are fairly obvious, they will simply be listed here: 64 | 65 | - ``to`` 66 | - ``subject`` 67 | - ``contents`` 68 | - ``attachments`` 69 | - ``cc`` 70 | - ``bcc`` 71 | - ``preview_only`` 72 | - ``headers`` 73 | 74 | Some of these - namely ``to`` and ``contents`` - have some magic 75 | associated with them which will be outlined in the following sections. 76 | 77 | 78 | E-Mail recipients 79 | ----------------- 80 | You can send an E-Mail to a single user by simply passing 81 | a string with either a GMail username (``@gmail.com`` will be appended 82 | automatically), or with a full E-Mail address: 83 | 84 | .. code-block:: python 85 | 86 | yag.send(to='mike@gmail.com', contents="Hello, Mike!") 87 | 88 | Alternatively, you can send E-Mails to a group of people by either passing 89 | a list or a tuple of E-Mail addresses as ``to``: 90 | 91 | .. code-block:: python 92 | 93 | yag.send(to=['to@someone.com', 'for@someone.com'], contents="Hello there!") 94 | 95 | These E-Mail addresses were passed without any aliases. 96 | If you wish to use aliases for the E-Mail addresses, provide a 97 | dictionary mapped in the form ``{address: alias}``, for example: 98 | 99 | .. code-block:: python 100 | 101 | recipients = { 102 | 'aliased@mike.com': 'Mike', 103 | 'aliased@fred.com': 'Fred' 104 | } 105 | yag.send(to=recipients, contents="Hello, Mike and Fred!") 106 | 107 | 108 | Magical ``contents`` 109 | -------------------- 110 | The ``contents`` argument of :meth:`yagmail.SMTP.send` will be smartly guessed. 111 | You can pass it a string with your contents or a list of elements which are either: 112 | 113 | - If it is a **dictionary**, then it will be assumed that the key is the content and the value is an alias (currently, this only applies to images). For example: 114 | 115 | 116 | .. code-block:: python 117 | 118 | contents = [ 119 | "Hello Mike! Here is a picture I took last week:", 120 | {'path/to/my/image.png': 'PictureForMike'} 121 | ] 122 | 123 | - If it is a **string**, then it will first check whether the content of the string can be **read as a file** locally, for example ``'path/to/my/image.png'``. These files require an extension for their content type to be inferred. 124 | 125 | - If it could not be read locally, then it checks whether the string is valid HTML, such as ``

This is a big title!

``. 126 | 127 | - If it was not valid HTML either, then it must be text, such as ``"Hello, Mike!"``. 128 | 129 | If you want to **ensure that a string is treated as text** and should not be checked 130 | for any other content as described above, you can use :class:`yagmail.raw`, a subclass 131 | of :class:`str`. 132 | 133 | If you intend to **inline an image instead of attaching it**, you can use 134 | :class:`yagmail.inline`. 135 | 136 | 137 | Using yagmail from the command line 138 | ----------------------------------- 139 | ``yagmail`` includes a command-line application, simply called with ``yagmail`` 140 | after you installed it. To view a full reference on how to use this, run 141 | ``yagmail --help``. 142 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_rpm] 2 | doc_files = README.rst 3 | 4 | [wheel] 5 | universal = 1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from setuptools import find_packages 3 | 4 | with open('README.rst') as f: 5 | LONG_DESCRIPTION = f.read() 6 | MAJOR_VERSION = '0' 7 | MINOR_VERSION = '15' 8 | MICRO_VERSION = '277' 9 | VERSION = "{}.{}.{}".format(MAJOR_VERSION, MINOR_VERSION, MICRO_VERSION) 10 | 11 | setup( 12 | name='yagmail', 13 | version=VERSION, 14 | description='Yet Another GMAIL client', 15 | long_description=LONG_DESCRIPTION, 16 | url='https://github.com/kootenpv/yagmail', 17 | author='Pascal van Kooten', 18 | author_email='kootenpv@gmail.com', 19 | license='MIT', 20 | extras_require={"all": ["keyring", "dkimpy"], "dkim": ["dkimpy"]}, 21 | install_requires=["premailer"], 22 | keywords='email mime automatic html attachment', 23 | entry_points={'console_scripts': ['yagmail = yagmail.__main__:main']}, 24 | classifiers=[ 25 | 'Environment :: Console', 26 | 'Intended Audience :: Developers', 27 | 'Intended Audience :: Customer Service', 28 | 'Intended Audience :: System Administrators', 29 | 'License :: OSI Approved :: MIT License', 30 | 'Operating System :: Microsoft', 31 | 'Operating System :: MacOS :: MacOS X', 32 | 'Operating System :: Unix', 33 | 'Operating System :: POSIX', 34 | 'Programming Language :: Python', 35 | 'Programming Language :: Python :: 3.6', 36 | 'Programming Language :: Python :: 3.7', 37 | 'Programming Language :: Python :: 3.8', 38 | 'Programming Language :: Python :: 3.9', 39 | 'Programming Language :: Python :: 3.10', 40 | 'Topic :: Communications :: Email', 41 | 'Topic :: Communications :: Email :: Email Clients (MUA)', 42 | 'Topic :: Software Development', 43 | 'Topic :: Software Development :: Build Tools', 44 | 'Topic :: Software Development :: Debuggers', 45 | 'Topic :: Software Development :: Libraries', 46 | 'Topic :: Software Development :: Libraries :: Python Modules', 47 | 'Topic :: System :: Software Distribution', 48 | 'Topic :: System :: Systems Administration', 49 | 'Topic :: Utilities', 50 | ], 51 | packages=find_packages(), 52 | zip_safe=False, 53 | platforms='any', 54 | ) 55 | -------------------------------------------------------------------------------- /tests/all_test.py: -------------------------------------------------------------------------------- 1 | """ Testing module for yagmail """ 2 | import itertools 3 | from yagmail import SMTP 4 | from yagmail import raw, inline 5 | 6 | 7 | def get_combinations(yag): 8 | """ Creates permutations of possible inputs """ 9 | tos = ( 10 | None, 11 | (yag.user), 12 | [yag.user, yag.user], 13 | {yag.user: '"me" <{}>'.format(yag.user), yag.user + '1': '"me" <{}>'.format(yag.user)}, 14 | ) 15 | subjects = ('subj', ['subj'], ['subj', 'subj1']) 16 | contents = ( 17 | None, 18 | ['body'], 19 | ['body', 'body1', '

Text

', u"

\u2013

"], 20 | [raw("body")], 21 | [{"a": 1}], 22 | ) 23 | results = [] 24 | for row in itertools.product(tos, subjects, contents): 25 | options = {y: z for y, z in zip(['to', 'subject', 'contents'], row)} 26 | options['preview_only'] = True 27 | results.append(options) 28 | 29 | return results 30 | 31 | 32 | def test_one(): 33 | """ Tests several versions of allowed input for yagmail """ 34 | yag = SMTP(smtp_skip_login=True, soft_email_validation=False) 35 | mail_combinations = get_combinations(yag) 36 | for combination in mail_combinations: 37 | print(yag.send(**combination)) 38 | -------------------------------------------------------------------------------- /tests/domainkey-dns.txt: -------------------------------------------------------------------------------- 1 | v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkwMu7eqAx9WrL4lwio01L65D425hBs54Aw4HODsHQYiwQejKsZdj+kneLpm9Zdvm3U1FDD+SfkBWGJmlScoj5Kg0nYx0c0RVeowKetVrmTL7t7d01ag+QRnCBHN1E/B99rFpy47WtwAOuPuKZKIc40JvkCphxVj6GbJZsPjyA2YuhLDp0zVvNzQ61mbM5OC50unppH73maqQVh4f3kIm3Cfxbe8yw8hfVlmZomuSwv3HpZLgrF4ktktI2f3q18Wx4e4OOHaanv/b8VrXo6qIV6RLH5FSteyzFfs+qZbbaWmSDjYEoIHS/oZkaNQOZOkr2T12Rnu/lk/ubDErqaCLXQIDAQAB -------------------------------------------------------------------------------- /tests/privkey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEogIBAAKCAQEAkwMu7eqAx9WrL4lwio01L65D425hBs54Aw4HODsHQYiwQejK 3 | sZdj+kneLpm9Zdvm3U1FDD+SfkBWGJmlScoj5Kg0nYx0c0RVeowKetVrmTL7t7d0 4 | 1ag+QRnCBHN1E/B99rFpy47WtwAOuPuKZKIc40JvkCphxVj6GbJZsPjyA2YuhLDp 5 | 0zVvNzQ61mbM5OC50unppH73maqQVh4f3kIm3Cfxbe8yw8hfVlmZomuSwv3HpZLg 6 | rF4ktktI2f3q18Wx4e4OOHaanv/b8VrXo6qIV6RLH5FSteyzFfs+qZbbaWmSDjYE 7 | oIHS/oZkaNQOZOkr2T12Rnu/lk/ubDErqaCLXQIDAQABAoIBADWdWpcgB9lZXnYW 8 | vLl66CO8fTvLfI077V7H1fA27t2CmS1gVdPQr4CPQf1iykUEnrykuoLOCIIMupl8 9 | J2Cy3MY+ZfnzSGDlUftAaW4EuZoEkvKccHqfQh0B5NU0ukUMVxQJ/dhj/oB8/+GM 10 | sxsiWEC1cPR10HRlj8ihV76H+9Mq+k9+/LrT8AU4qJHTZCwNvS/IESz67uutqtn2 11 | EgYN70QPIgQLYDCLiH8D3d3bE/YfOBLMJNxWYDIFcUtDtRmvB8Qrx+dzhp1wOVj6 12 | Ouwav5e+ZZu2LKkbRiENxjR9OrcHHcVdnuNYIfGnriPrksqljTurgamr+7zJo2Dg 13 | dalgNAECgYEAxqBXSu+YyMuT24KY01KXYDB4iK0J9XbF/Zd9VKCG4sJe/ZQWqjSH 14 | 1IMb2yDQTNVic0NSQnQ6tu55UT+Avw0y4VsYsEdeyf2Y1wi+K7L2VLqny2ihjsNE 15 | pN7kJO31NPc4rELsxzxU6nQK4EAAlZs2H5BZSmPxbe0+gB3x2B23dwECgYEAvXox 16 | ESTKfn/XXTZOrS1Iv2zEonlCu5ERroAcFi8BKV3TpCOQfWDYUIflrksDkRAHjMl1 17 | tyNmT/fPBLH9EL4CHevPOpUweHsG9LNyp3An5IpcOorzD3TTfEl+FSKZ1D4uUYeD 18 | rOx7QVCSrOAtbLQlP5Oc61blY5JINxB1TaLKUF0CgYBdd+acJNPI6cPScEpqZ1tE 19 | sIqIBqXBFPtmsnsP79qJqt34hk+EGOQyZOAe5fofrep+QxfancdjfiUozrFPNm7T 20 | DYM4sN0yQFxEFKEo/zZb+NotJjegbtNGonzJxBC3s/6/UV8LAqETEzhq/rNHs5ps 21 | kAj0sMNT72iR8YV1JcbIAQKBgGzyfJIh+HkCIyBKoLR8zE6dSPcvCEr3YBZZPU0Y 22 | G+/gLlg7xtIAxICRk2RDZ7qaX+z4zcHPDf4/O/60JRHiXy87Lr29mNA91UMQh4V1 23 | PMrxL5TN3nJtt0jIrUGT0qWyV0mzxOfCViC5Jo1WnWfasWw8AUdkgKNfMjzPLtPE 24 | HdZVAoGAMw4Ffpdy1UPzB/nSJYzhGyXpHOWfrkfwyzTFpnKw0BeGA1AYWNxVIKHY 25 | zqVtTBTSZcLJc4+4oVYuE7Qpvyx3fIuyDoVJXgUohCk8z5HtshevGeYuNQd2Vj94 26 | TigapgfF4hY6cBjA4hNIUlgWF1scX7aNEvq1EDGcy+SwjUiR+J0= 27 | -----END RSA PRIVATE KEY----- -------------------------------------------------------------------------------- /tests/test_dkim.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | from pathlib import Path 4 | from unittest.mock import Mock 5 | 6 | import dkim 7 | 8 | 9 | def get_txt_from_test_file(*args, **kwargs): 10 | dns_data_file = Path(__file__).parent / "domainkey-dns.txt" 11 | 12 | return Path(dns_data_file).read_bytes() 13 | 14 | 15 | def _test_email_with_dkim(include_headers): 16 | from yagmail import SMTP 17 | from yagmail.dkim import DKIM 18 | 19 | private_key_path = Path(__file__).parent / "privkey.pem" 20 | 21 | private_key = private_key_path.read_bytes() 22 | 23 | dkim_obj = DKIM( 24 | domain=b"a.com", 25 | selector=b"selector", 26 | private_key=private_key, 27 | include_headers=include_headers, 28 | ) 29 | 30 | yag = SMTP( 31 | user="a@a.com", 32 | host="smtp.blabla.com", 33 | port=25, 34 | dkim=dkim_obj, 35 | ) 36 | 37 | yag.login = Mock() 38 | 39 | to = "b@b.com" 40 | 41 | recipients, msg_bytes = yag.send( 42 | to=to, 43 | subject="hello from tests", 44 | contents="important message", 45 | preview_only=True 46 | ) 47 | 48 | msg_string = msg_bytes.decode("utf8") 49 | 50 | assert recipients == [to] 51 | assert "Subject: hello from tests" in msg_string 52 | text_b64 = base64.b64encode(b"important message").decode("utf8") 53 | assert text_b64 in msg_string 54 | 55 | dkim_string1 = "DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=a.com; i=@a.com;\n " \ 56 | "q=dns/txt; s=selector; t=" 57 | assert dkim_string1 in msg_string 58 | 59 | l = logging.getLogger() 60 | l.setLevel(level=logging.DEBUG) 61 | logging.basicConfig(level=logging.DEBUG) 62 | 63 | assert dkim.verify( 64 | message=msg_string.encode("utf8"), 65 | logger=l, 66 | dnsfunc=get_txt_from_test_file 67 | ) 68 | 69 | return msg_string 70 | 71 | 72 | def test_email_with_dkim(): 73 | msg_string = _test_email_with_dkim(include_headers=[b"To", b"From", b"Subject"]) 74 | 75 | dkim_string2 = "h=to : from : subject;" 76 | assert dkim_string2 in msg_string 77 | 78 | 79 | def test_dkim_without_including_headers(): 80 | msg_string = _test_email_with_dkim(include_headers=None) 81 | 82 | dkim_string_headers = "h=content-type : mime-version :\n date : subject : from : to : message-id : from;\n" 83 | assert dkim_string_headers in msg_string 84 | 85 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36,py37,py38,py39,py310 3 | 4 | [testenv] 5 | # If you add a new dep here you probably need to add it in setup.py as well 6 | passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH 7 | setenv = 8 | PYTHON_ENV = dev 9 | deps = 10 | pytest 11 | pytest-cov 12 | coveralls 13 | commands = 14 | py.test --cov ./yagmail 15 | coveralls 16 | -------------------------------------------------------------------------------- /yagmail/__init__.py: -------------------------------------------------------------------------------- 1 | __project__ = "yagmail" 2 | __version__ = "0.15.277" 3 | 4 | from yagmail.error import YagConnectionClosed 5 | from yagmail.error import YagAddressError 6 | from yagmail.password import register 7 | from yagmail.sender import SMTP 8 | from yagmail.sender import logging 9 | from yagmail.utils import raw 10 | from yagmail.utils import inline 11 | -------------------------------------------------------------------------------- /yagmail/__main__.py: -------------------------------------------------------------------------------- 1 | from yagmail.sender import SMTP 2 | import sys 3 | 4 | try: 5 | import keyring 6 | except (ImportError, NameError, RuntimeError): 7 | pass 8 | 9 | 10 | def register(username, password): 11 | """ Use this to add a new gmail account to your OS' keyring so it can be used in yagmail """ 12 | keyring.set_password("yagmail", username, password) 13 | 14 | 15 | def main(): 16 | """ This is the function that is run from commandline with `yagmail` """ 17 | import argparse 18 | 19 | parser = argparse.ArgumentParser(description="Send a (g)mail with yagmail.") 20 | parser.add_argument("-to", "-t", help='Send an email to address "TO"', nargs="+") 21 | parser.add_argument("-subject", "-s", help="Subject of email", nargs="+") 22 | parser.add_argument("-contents", "-c", help="Contents to send", nargs="+") 23 | parser.add_argument("-attachments", "-a", help="Attachments to attach", nargs="+") 24 | parser.add_argument("-user", "-u", help="Username") 25 | parser.add_argument( 26 | "-password", "-p", help="Preferable to use keyring rather than password here" 27 | ) 28 | args = parser.parse_args() 29 | yag = SMTP(args.user, args.password) 30 | yag.send(to=args.to, subject=args.subject, contents=args.contents, attachments=args.attachments) 31 | -------------------------------------------------------------------------------- /yagmail/compat.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | PY3 = sys.version_info[0] == 3 5 | text_type = (str,) if PY3 else (str, unicode) 6 | -------------------------------------------------------------------------------- /yagmail/dkim.py: -------------------------------------------------------------------------------- 1 | from email.mime.base import MIMEBase 2 | from typing import NamedTuple 3 | 4 | try: 5 | import dkim 6 | except ImportError: 7 | dkim = None 8 | pass 9 | 10 | 11 | class DKIM(NamedTuple): 12 | domain: bytes 13 | private_key: bytes 14 | include_headers: list 15 | selector: bytes 16 | 17 | 18 | def add_dkim_sig_to_message(msg: MIMEBase, dkim_obj: DKIM) -> None: 19 | if dkim is None: 20 | raise RuntimeError("dkim package not installed") 21 | 22 | # Based on example from: 23 | # https://github.com/russellballestrini/russell.ballestrini.net/blob/master/content/ 24 | # 2018-06-04-quickstart-to-dkim-sign-email-with-python.rst 25 | sig = dkim.sign( 26 | message=msg.as_bytes(), 27 | selector=dkim_obj.selector, 28 | domain=dkim_obj.domain, 29 | privkey=dkim_obj.private_key, 30 | include_headers=dkim_obj.include_headers, 31 | ) 32 | # add the dkim signature to the email message headers. 33 | # decode the signature back to string_type because later on 34 | # the call to msg.as_string() performs it's own bytes encoding... 35 | msg["DKIM-Signature"] = sig[len("DKIM-Signature: "):].decode() 36 | -------------------------------------------------------------------------------- /yagmail/error.py: -------------------------------------------------------------------------------- 1 | """Contains the exceptions""" 2 | 3 | 4 | class YagConnectionClosed(Exception): 5 | 6 | """ 7 | The connection object has been closed by the user. 8 | This object can be used to send emails again after logging in, 9 | using self.login(). 10 | """ 11 | pass 12 | 13 | 14 | class YagAddressError(Exception): 15 | 16 | """ 17 | This means that the address was given in an invalid format. 18 | Note that From can either be a string, or a dictionary where the key is an email, 19 | and the value is an alias {'sample@gmail.com', 'Sam'}. In the case of 'to', 20 | it can either be a string (email), a list of emails (email addresses without aliases) 21 | or a dictionary where keys are the email addresses and the values indicate the aliases. 22 | Furthermore, it does not do any validation of whether an email exists. 23 | """ 24 | pass 25 | 26 | 27 | class YagInvalidEmailAddress(Exception): 28 | 29 | """ 30 | Note that this will only filter out syntax mistakes in emailaddresses. 31 | If a human would think it is probably a valid email, it will most likely pass. 32 | However, it could still very well be that the actual emailaddress has simply 33 | not be claimed by anyone (so then this function fails to devalidate). 34 | """ 35 | pass 36 | -------------------------------------------------------------------------------- /yagmail/example.html: -------------------------------------------------------------------------------- 1 |

Bla

2 | -------------------------------------------------------------------------------- /yagmail/headers.py: -------------------------------------------------------------------------------- 1 | import time 2 | import random 3 | import hashlib 4 | from yagmail.compat import text_type 5 | from yagmail.error import YagAddressError 6 | from email.utils import formataddr 7 | 8 | 9 | def resolve_addresses(user, useralias, to, cc, bcc): 10 | """ Handle the targets addresses, adding aliases when defined """ 11 | addresses = {"recipients": []} 12 | if to is not None: 13 | make_addr_alias_target(to, addresses, "To") 14 | elif cc is not None and bcc is not None: 15 | make_addr_alias_target([user, useralias], addresses, "To") 16 | else: 17 | addresses["recipients"].append(user) 18 | if cc is not None: 19 | make_addr_alias_target(cc, addresses, "Cc") 20 | if bcc is not None: 21 | make_addr_alias_target(bcc, addresses, "Bcc") 22 | return addresses 23 | 24 | 25 | def make_addr_alias_user(email_addr): 26 | if isinstance(email_addr, text_type): 27 | if "@" not in email_addr: 28 | email_addr += "@gmail.com" 29 | return (email_addr, email_addr) 30 | if isinstance(email_addr, dict): 31 | if len(email_addr) == 1: 32 | return (list(email_addr.keys())[0], list(email_addr.values())[0]) 33 | raise YagAddressError 34 | 35 | 36 | def make_addr_alias_target(x, addresses, which): 37 | if isinstance(x, text_type): 38 | addresses["recipients"].append(x) 39 | addresses[which] = x 40 | elif isinstance(x, list) or isinstance(x, tuple): 41 | if not all([isinstance(k, text_type) for k in x]): 42 | raise YagAddressError 43 | addresses["recipients"].extend(x) 44 | addresses[which] = ",".join(x) 45 | elif isinstance(x, dict): 46 | addresses["recipients"].extend(x.keys()) 47 | addresses[which] = ",".join(x.values()) 48 | else: 49 | raise YagAddressError 50 | 51 | 52 | def add_subject(msg, subject): 53 | if not subject: 54 | return 55 | if isinstance(subject, list): 56 | subject = " ".join(subject) 57 | msg["Subject"] = subject 58 | 59 | 60 | def add_recipients_headers(user, useralias, msg, addresses): 61 | # Quoting the useralias so it should match display-name from https://tools.ietf.org/html/rfc5322 , 62 | # even if it's an email address. 63 | # msg["From"] = '"{0}" <{1}>'.format(useralias.replace("\\", "\\\\").replace('"', '\\"'), user) 64 | # formataddr can support From chinese useralias, just like: mail_user = {'notice@test.com': '中文测试'} 65 | msg['From'] = formataddr(["{0}".format(useralias.replace("\\", "\\\\").replace('"', '\\"')), user]) 66 | if "To" in addresses: 67 | msg["To"] = addresses["To"] 68 | else: 69 | msg["To"] = useralias 70 | if "Cc" in addresses: 71 | msg["Cc"] = addresses["Cc"] 72 | 73 | 74 | def add_message_id(msg, message_id=None, group_messages=True): 75 | if message_id is None: 76 | if group_messages: 77 | addr = " ".join(sorted([msg["From"], msg["To"]])) + msg.get("Subject", "None") 78 | else: 79 | addr = str(time.time() + random.random()) 80 | message_id = "<" + hashlib.md5(addr.encode()).hexdigest() + "@yagmail>" 81 | msg["Message-ID"] = message_id 82 | -------------------------------------------------------------------------------- /yagmail/log.py: -------------------------------------------------------------------------------- 1 | """ 2 | The logging options for yagmail. Note that the logger is set on the SMTP class. 3 | 4 | The default is to only log errors. If wanted, it is possible to do logging with: 5 | 6 | yag = SMTP() 7 | yag.setLog(log_level = logging.DEBUG) 8 | 9 | Furthermore, after creating a SMTP object, it is possible to overwrite and use your own logger by: 10 | 11 | yag = SMTP() 12 | yag.log = myOwnLogger 13 | """ 14 | 15 | import logging 16 | 17 | 18 | def get_logger(log_level=logging.DEBUG, file_path_name=None): 19 | 20 | # create logger 21 | logger = logging.getLogger(__name__) 22 | 23 | logger.setLevel(logging.ERROR) 24 | 25 | # create console handler and set level to debug 26 | if file_path_name: 27 | ch = logging.FileHandler(file_path_name) 28 | elif log_level is None: 29 | logger.handlers = [logging.NullHandler()] 30 | return logger 31 | else: 32 | ch = logging.StreamHandler() 33 | 34 | ch.setLevel(log_level) 35 | 36 | # create formatter 37 | formatter = logging.Formatter( 38 | "%(asctime)s [yagmail] [%(levelname)s] : %(message)s", "%Y-%m-%d %H:%M:%S" 39 | ) 40 | 41 | # add formatter to ch 42 | ch.setFormatter(formatter) 43 | 44 | # add ch to logger 45 | logger.handlers = [ch] 46 | 47 | return logger 48 | -------------------------------------------------------------------------------- /yagmail/message.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import email.encoders 3 | import io 4 | import json 5 | import mimetypes 6 | import os 7 | import sys 8 | from email.mime.base import MIMEBase 9 | from email.mime.multipart import MIMEMultipart 10 | from email.mime.text import MIMEText 11 | from email.utils import formatdate 12 | 13 | from yagmail.dkim import add_dkim_sig_to_message 14 | from yagmail.headers import add_message_id 15 | from yagmail.headers import add_recipients_headers 16 | from yagmail.headers import add_subject 17 | from yagmail.utils import raw, inline 18 | 19 | PY3 = sys.version_info[0] > 2 20 | 21 | 22 | def dt_converter(o): 23 | if isinstance(o, (datetime.date, datetime.datetime)): 24 | return o.isoformat() 25 | 26 | 27 | def serialize_object(content): 28 | is_marked_up = False 29 | if isinstance(content, (dict, list, tuple, set)): 30 | content = "
" + json.dumps(content, indent=4, default=dt_converter) + "
" 31 | is_marked_up = True 32 | elif "DataFrame" in content.__class__.__name__: 33 | try: 34 | content = content.render() 35 | except AttributeError: 36 | content = content.to_html() 37 | is_marked_up = True 38 | return is_marked_up, content 39 | 40 | 41 | def prepare_message( 42 | user, 43 | useralias, 44 | addresses, 45 | subject, 46 | contents, 47 | attachments, 48 | headers, 49 | encoding, 50 | prettify_html=True, 51 | message_id=None, 52 | group_messages=True, 53 | dkim=None, 54 | ): 55 | # check if closed!!!!!! XXX 56 | """Prepare a MIME message""" 57 | 58 | if not isinstance(contents, (list, tuple)): 59 | if contents is not None: 60 | contents = [contents] 61 | if not isinstance(attachments, (list, tuple)): 62 | if attachments is not None: 63 | attachments = [attachments] 64 | # merge contents and attachments for now. 65 | if attachments is not None: 66 | for a in attachments: 67 | if not isinstance(a, io.IOBase) and not os.path.isfile(a): 68 | msg = "{a} must be a valid filepath or file handle (instance of io.IOBase). {a} is of type {tp}" 69 | raise TypeError(msg.format(a=a, tp=type(a))) 70 | contents = attachments if contents is None else contents + attachments 71 | 72 | if contents is not None: 73 | contents = [serialize_object(x) for x in contents] 74 | 75 | has_included_images, content_objects = prepare_contents(contents, encoding) 76 | if contents is not None: 77 | contents = [x[1] for x in contents] 78 | 79 | msg = MIMEMultipart() 80 | if headers is not None: 81 | # Strangely, msg does not have an update method, so then manually. 82 | for k, v in headers.items(): 83 | msg[k] = v 84 | if headers is None or "Date" not in headers: 85 | msg["Date"] = formatdate() 86 | 87 | msg_alternative = MIMEMultipart("alternative") 88 | msg_related = MIMEMultipart("related") 89 | msg_related.attach("-- HTML goes here --") 90 | msg.attach(msg_alternative) 91 | add_subject(msg, subject) 92 | add_recipients_headers(user, useralias, msg, addresses) 93 | add_message_id(msg, message_id, group_messages) 94 | htmlstr = "" 95 | altstr = [] 96 | if has_included_images: 97 | msg.preamble = "This message is best displayed using a MIME capable email reader." 98 | 99 | if contents is not None: 100 | for content_object, content_string in zip(content_objects, contents): 101 | if content_object["main_type"] == "image": 102 | # all image objects need base64 encoding, so do it now 103 | email.encoders.encode_base64(content_object["mime_object"]) 104 | # aliased image {'path' : 'alias'} 105 | if isinstance(content_string, dict) and len(content_string) == 1: 106 | for key in content_string: 107 | hashed_ref = str(abs(hash(key))) 108 | alias = content_string[key] 109 | # pylint: disable=undefined-loop-variable 110 | content_string = key 111 | else: 112 | alias = os.path.basename(str(content_string)) 113 | hashed_ref = str(abs(hash(alias))) 114 | 115 | # TODO: I should probably remove inline now that there is "attachments" 116 | # if string is `inline`, inline, else, attach 117 | # pylint: disable=unidiomatic-typecheck 118 | if type(content_string) == inline: 119 | htmlstr += ''.format(hashed_ref, alias) 120 | content_object["mime_object"].add_header("Content-ID", "<{0}>".format(hashed_ref)) 121 | altstr.append("-- img {0} should be here -- ".format(alias)) 122 | # inline images should be in related MIME block 123 | msg_related.attach(content_object["mime_object"]) 124 | else: 125 | # non-inline images get attached like any other attachment 126 | msg.attach(content_object["mime_object"]) 127 | 128 | else: 129 | if content_object["encoding"] == "base64": 130 | email.encoders.encode_base64(content_object["mime_object"]) 131 | msg.attach(content_object["mime_object"]) 132 | elif content_object["sub_type"] not in ["html", "plain"]: 133 | msg.attach(content_object["mime_object"]) 134 | else: 135 | if not content_object["is_marked_up"]: 136 | content_string = content_string.replace("\n", "
") 137 | try: 138 | htmlstr += "
{0}
".format(content_string) 139 | if PY3 and prettify_html: 140 | import premailer 141 | 142 | htmlstr = premailer.transform(htmlstr) 143 | except UnicodeEncodeError: 144 | htmlstr += u"
{0}
".format(content_string) 145 | altstr.append(content_string) 146 | 147 | msg_related.get_payload()[0] = MIMEText(htmlstr, "html", _charset=encoding) 148 | msg_alternative.attach(MIMEText("\n".join(altstr), _charset=encoding)) 149 | msg_alternative.attach(msg_related) 150 | 151 | if dkim is not None: 152 | add_dkim_sig_to_message(msg, dkim) 153 | 154 | return msg 155 | 156 | 157 | def prepare_contents(contents, encoding): 158 | mime_objects = [] 159 | has_included_images = False 160 | if contents is not None: 161 | unnamed_attachment_id = 1 162 | for is_marked_up, content in contents: 163 | if isinstance(content, io.IOBase): 164 | if not hasattr(content, "name"): 165 | # If the IO object has no name attribute, give it one. 166 | content.name = "attachment_{}".format(unnamed_attachment_id) 167 | 168 | content_object = get_mime_object(is_marked_up, content, encoding) 169 | if content_object["main_type"] == "image": 170 | has_included_images = True 171 | mime_objects.append(content_object) 172 | return has_included_images, mime_objects 173 | 174 | 175 | def get_mime_object(is_marked_up, content_string, encoding): 176 | content_object = { 177 | "mime_object": None, 178 | "encoding": None, 179 | "main_type": None, 180 | "sub_type": None, 181 | "is_marked_up": is_marked_up, 182 | } 183 | try: 184 | content_name = os.path.basename(str(content_string)) 185 | except UnicodeEncodeError: 186 | content_name = os.path.basename(content_string) 187 | # pylint: disable=unidiomatic-typecheck 188 | is_raw = type(content_string) == raw 189 | try: 190 | is_file = os.path.isfile(content_string) 191 | except ValueError: 192 | is_file = False 193 | content_name = str(abs(hash(content_string))) 194 | except TypeError: 195 | # This happens when e.g. tuple is passed. 196 | is_file = False 197 | if not is_raw and is_file: 198 | with open(content_string, "rb") as f: 199 | content_object["encoding"] = "base64" 200 | content = f.read() 201 | 202 | elif isinstance(content_string, io.IOBase): 203 | content = content_string.read() 204 | # no need to except AttributeError, as we set missing name attributes in the `prepare_contents` function 205 | content_name = os.path.basename(content_string.name) 206 | content_object["encoding"] = "base64" 207 | 208 | else: 209 | content_object["main_type"] = "text" 210 | 211 | if is_raw: 212 | content_object["mime_object"] = MIMEText(content_string, _charset=encoding) 213 | else: 214 | content_object["mime_object"] = MIMEText(content_string, "html", _charset=encoding) 215 | content_object["sub_type"] = "html" 216 | 217 | if content_object["sub_type"] is None: 218 | content_object["sub_type"] = "plain" 219 | return content_object 220 | 221 | if content_object["main_type"] is None: 222 | # Guess the mimetype with the filename 223 | content_type, _ = mimetypes.guess_type(content_name) 224 | 225 | if content_type is not None: 226 | content_object["main_type"], content_object["sub_type"] = content_type.split("/") 227 | 228 | if content_object["main_type"] is None or content_object["encoding"] is not None: 229 | if content_object["encoding"] != "base64": 230 | content_object["main_type"] = "application" 231 | content_object["sub_type"] = "octet-stream" 232 | 233 | mime_object = MIMEBase(content_object["main_type"], content_object["sub_type"], name=(encoding, "", content_name)) 234 | mime_object.set_payload(content) 235 | if content_object["main_type"] == "application": 236 | mime_object.add_header("Content-Disposition", "attachment", filename=content_name) 237 | content_object["mime_object"] = mime_object 238 | return content_object 239 | -------------------------------------------------------------------------------- /yagmail/oauth2.py: -------------------------------------------------------------------------------- 1 | """ 2 | Adapted from: 3 | http://blog.macuyiko.com/post/2016/how-to-send-html-mails-with-oauth2-and-gmail-in-python.html 4 | 5 | 1. Generate and authorize an OAuth2 (generate_oauth2_token) 6 | 2. Generate a new access tokens using a refresh token(refresh_token) 7 | 3. Generate an OAuth2 string to use for login (access_token) 8 | """ 9 | import os 10 | import base64 11 | import json 12 | import getpass 13 | 14 | try: 15 | from urllib.parse import urlencode, quote, unquote, parse_qs, urlsplit 16 | from urllib.request import urlopen 17 | except ImportError: 18 | from urllib import urlencode, quote, unquote, urlopen 19 | from urlparse import parse_qs, urlsplit 20 | 21 | try: 22 | input = raw_input 23 | except NameError: 24 | pass 25 | 26 | GOOGLE_ACCOUNTS_BASE_URL = 'https://accounts.google.com' 27 | REDIRECT_URI = 'http://localhost' 28 | 29 | 30 | def command_to_url(command): 31 | return '%s/%s' % (GOOGLE_ACCOUNTS_BASE_URL, command) 32 | 33 | 34 | def url_format_params(params): 35 | param_fragments = [] 36 | for param in sorted(params.items(), key=lambda x: x[0]): 37 | escaped_url = quote(param[1], safe='~-._') 38 | param_fragments.append('%s=%s' % (param[0], escaped_url)) 39 | return '&'.join(param_fragments) 40 | 41 | 42 | def generate_permission_url(client_id): 43 | params = {} 44 | params['client_id'] = client_id 45 | params['redirect_uri'] = REDIRECT_URI 46 | params['scope'] = 'https://mail.google.com/' 47 | params['response_type'] = 'code' 48 | return '%s?%s' % (command_to_url('o/oauth2/auth'), url_format_params(params)) 49 | 50 | 51 | def call_authorize_tokens(client_id, client_secret, authorization_code): 52 | params = {} 53 | params['client_id'] = client_id 54 | params['client_secret'] = client_secret 55 | params['code'] = authorization_code 56 | params['redirect_uri'] = REDIRECT_URI 57 | params['grant_type'] = 'authorization_code' 58 | request_url = command_to_url('o/oauth2/token') 59 | encoded_params = urlencode(params).encode('UTF-8') 60 | response = urlopen(request_url, encoded_params).read().decode('UTF-8') 61 | return json.loads(response) 62 | 63 | 64 | def call_refresh_token(client_id, client_secret, refresh_token): 65 | params = {} 66 | params['client_id'] = client_id 67 | params['client_secret'] = client_secret 68 | params['refresh_token'] = refresh_token 69 | params['grant_type'] = 'refresh_token' 70 | request_url = command_to_url('o/oauth2/token') 71 | encoded_params = urlencode(params).encode('UTF-8') 72 | response = urlopen(request_url, encoded_params).read().decode('UTF-8') 73 | return json.loads(response) 74 | 75 | 76 | def generate_oauth2_string(username, access_token, as_base64=False): 77 | auth_string = 'user=%s\1auth=Bearer %s\1\1' % (username, access_token) 78 | if as_base64: 79 | auth_string = base64.b64encode(auth_string.encode('ascii')).decode('ascii') 80 | return auth_string 81 | 82 | 83 | def get_authorization(google_client_id, google_client_secret): 84 | permission_url = generate_permission_url(google_client_id) 85 | print('Navigate to the following URL to auth:\n' + permission_url) 86 | url = input('Enter the localhost URL you were redirected to: ') 87 | authorization_code = parse_qs(urlsplit(url).query)['code'][0] 88 | response = call_authorize_tokens(google_client_id, google_client_secret, authorization_code) 89 | return response['refresh_token'], response['access_token'], response['expires_in'] 90 | 91 | 92 | def refresh_authorization(google_client_id, google_client_secret, google_refresh_token): 93 | response = call_refresh_token(google_client_id, google_client_secret, google_refresh_token) 94 | return response['access_token'], response['expires_in'] 95 | 96 | 97 | def get_oauth_string(user, oauth2_info): 98 | access_token, expires_in = refresh_authorization(**oauth2_info) 99 | auth_string = generate_oauth2_string(user, access_token, as_base64=True) 100 | return auth_string 101 | 102 | 103 | def get_oauth2_info(oauth2_file: str, email_addr: str): 104 | oauth_setup_readme_link = "See readme for proper setup, preventing authorization from expiring after 7 days! https://github.com/kootenpv/yagmail/blob/master/README.md#preventing-oauth-authorization-from-expiring-after-7-days" 105 | oauth2_file = os.path.expanduser(oauth2_file) 106 | if os.path.isfile(oauth2_file): 107 | with open(oauth2_file) as f: 108 | oauth2_info = json.load(f) 109 | try: 110 | oauth2_info = oauth2_info["installed"] 111 | except KeyError: 112 | return oauth2_info 113 | print(oauth_setup_readme_link) 114 | if email_addr is None: 115 | email_addr = input("Your 'email address': ") 116 | google_client_id = oauth2_info["client_id"] 117 | google_client_secret = oauth2_info["client_secret"] 118 | google_refresh_token, _, _ = get_authorization(google_client_id, google_client_secret) 119 | oauth2_info = { 120 | "email_address": email_addr, 121 | "google_client_id": google_client_id.strip(), 122 | "google_client_secret": google_client_secret.strip(), 123 | "google_refresh_token": google_refresh_token.strip(), 124 | } 125 | with open(oauth2_file, "w") as f: 126 | json.dump(oauth2_info, f) 127 | else: 128 | print("If you do not have an app registered for your email sending purposes, visit:") 129 | print("https://console.developers.google.com") 130 | print("and create a new project.\n") 131 | print(oauth_setup_readme_link) 132 | if email_addr is None: 133 | email_addr = input("Your 'email address': ") 134 | google_client_id = input("Your 'google_client_id': ") 135 | google_client_secret = getpass.getpass("Your 'google_client_secret': ") 136 | google_refresh_token, _, _ = get_authorization(google_client_id, google_client_secret) 137 | oauth2_info = { 138 | "email_address": email_addr, 139 | "google_client_id": google_client_id.strip(), 140 | "google_client_secret": google_client_secret.strip(), 141 | "google_refresh_token": google_refresh_token.strip(), 142 | } 143 | with open(oauth2_file, "w") as f: 144 | json.dump(oauth2_info, f) 145 | return oauth2_info 146 | -------------------------------------------------------------------------------- /yagmail/password.py: -------------------------------------------------------------------------------- 1 | try: 2 | import keyring 3 | except (ImportError, NameError, RuntimeError): 4 | pass 5 | 6 | 7 | def handle_password(user, password): # pragma: no cover 8 | """ Handles getting the password""" 9 | if password is None: 10 | try: 11 | password = keyring.get_password("yagmail", user) 12 | except NameError as e: 13 | print( 14 | "'keyring' cannot be loaded. Try 'pip install keyring' or continue without. See https://github.com/kootenpv/yagmail" 15 | ) 16 | raise e 17 | if password is None: 18 | import getpass 19 | 20 | password = getpass.getpass("Password for <{0}>: ".format(user)) 21 | answer = "" 22 | # Python 2 fix 23 | while answer != "y" and answer != "n": 24 | prompt_string = "Save username and password in keyring? [y/n]: " 25 | # pylint: disable=undefined-variable 26 | try: 27 | answer = raw_input(prompt_string).strip() 28 | except NameError: 29 | answer = input(prompt_string).strip() 30 | if answer == "y": 31 | register(user, password) 32 | return password 33 | 34 | 35 | def register(username, password): 36 | """ Use this to add a new gmail account to your OS' keyring so it can be used in yagmail """ 37 | keyring.set_password("yagmail", username, password) 38 | -------------------------------------------------------------------------------- /yagmail/sender.py: -------------------------------------------------------------------------------- 1 | # when there is a bcc a different message has to be sent to the bcc 2 | # person, to show that they are bcc'ed 3 | 4 | import time 5 | import logging 6 | import smtplib 7 | 8 | from yagmail.log import get_logger 9 | from yagmail.utils import find_user_home_path 10 | from yagmail.oauth2 import get_oauth2_info, get_oauth_string 11 | from yagmail.headers import resolve_addresses 12 | from yagmail.validate import validate_email_with_regex 13 | from yagmail.password import handle_password 14 | from yagmail.message import prepare_message 15 | from yagmail.headers import make_addr_alias_user 16 | 17 | 18 | class SMTP: 19 | """ :class:`yagmail.SMTP` is a magic wrapper around 20 | ``smtplib``'s SMTP connection, and allows messages to be sent.""" 21 | 22 | def __init__( 23 | self, 24 | user=None, 25 | password=None, 26 | host="smtp.gmail.com", 27 | port=None, 28 | smtp_starttls=None, 29 | smtp_ssl=True, 30 | smtp_set_debuglevel=0, 31 | smtp_skip_login=False, 32 | encoding="utf-8", 33 | oauth2_file=None, 34 | soft_email_validation=True, 35 | dkim=None, 36 | **kwargs 37 | ): 38 | self.log = get_logger() 39 | self.set_logging() 40 | self.soft_email_validation = soft_email_validation 41 | if oauth2_file is not None: 42 | oauth2_info = get_oauth2_info(oauth2_file, user) 43 | if user is None: 44 | user = oauth2_info["email_address"] 45 | if smtp_skip_login and user is None: 46 | user = "" 47 | elif user is None: 48 | user = find_user_home_path() 49 | self.user, self.useralias = make_addr_alias_user(user) 50 | if soft_email_validation: 51 | validate_email_with_regex(self.user) 52 | self.is_closed = None 53 | self.host = host 54 | self.port = str(port) if port is not None else "465" if smtp_ssl else "587" 55 | self.smtp_starttls = smtp_starttls 56 | self.ssl = smtp_ssl 57 | self.smtp_skip_login = smtp_skip_login 58 | self.debuglevel = smtp_set_debuglevel 59 | self.encoding = encoding 60 | self.kwargs = kwargs 61 | self.cache = {} 62 | self.unsent = [] 63 | self.num_mail_sent = 0 64 | self.oauth2_file = oauth2_file 65 | self.credentials = password if oauth2_file is None else oauth2_info 66 | self.dkim = dkim 67 | 68 | def __enter__(self): 69 | return self 70 | 71 | def __exit__(self, exc_type, exc_val, exc_tb): 72 | if not self.is_closed: 73 | self.close() 74 | return False 75 | 76 | @property 77 | def connection(self): 78 | return smtplib.SMTP_SSL if self.ssl else smtplib.SMTP 79 | 80 | @property 81 | def starttls(self): 82 | if self.smtp_starttls is None: 83 | return False if self.ssl else True 84 | return self.smtp_starttls 85 | 86 | def set_logging(self, log_level=logging.ERROR, file_path_name=None): 87 | """ 88 | This function allows to change the logging backend, either output or file as backend 89 | It also allows to set the logging level (whether to display only critical/error/info/debug. 90 | for example:: 91 | 92 | yag = yagmail.SMTP() 93 | yag.set_logging(yagmail.logging.DEBUG) # to see everything 94 | 95 | and:: 96 | 97 | yagmail.set_logging(yagmail.logging.DEBUG, 'somelocalfile.log') 98 | 99 | lastly, a log_level of :py:class:`None` will make sure there is no I/O. 100 | """ 101 | self.log = get_logger(log_level, file_path_name) 102 | 103 | def prepare_send( 104 | self, 105 | to=None, 106 | subject=None, 107 | contents=None, 108 | attachments=None, 109 | cc=None, 110 | bcc=None, 111 | headers=None, 112 | prettify_html=True, 113 | message_id=None, 114 | group_messages=True, 115 | ): 116 | addresses = resolve_addresses(self.user, self.useralias, to, cc, bcc) 117 | 118 | if self.soft_email_validation: 119 | for email_addr in addresses["recipients"]: 120 | validate_email_with_regex(email_addr) 121 | 122 | msg = prepare_message( 123 | self.user, 124 | self.useralias, 125 | addresses, 126 | subject, 127 | contents, 128 | attachments, 129 | headers, 130 | self.encoding, 131 | prettify_html, 132 | message_id, 133 | group_messages, 134 | self.dkim, 135 | ) 136 | 137 | recipients = addresses["recipients"] 138 | msg_strings = msg.as_string() 139 | return recipients, msg_strings 140 | 141 | def send( 142 | self, 143 | to=None, 144 | subject=None, 145 | contents=None, 146 | attachments=None, 147 | cc=None, 148 | bcc=None, 149 | preview_only=False, 150 | headers=None, 151 | prettify_html=True, 152 | message_id=None, 153 | group_messages=True, 154 | ): 155 | """ Use this to send an email with gmail""" 156 | self.login() 157 | recipients, msg_strings = self.prepare_send( 158 | to, 159 | subject, 160 | contents, 161 | attachments, 162 | cc, 163 | bcc, 164 | headers, 165 | prettify_html, 166 | message_id, 167 | group_messages, 168 | ) 169 | if preview_only: 170 | return recipients, msg_strings 171 | 172 | return self._attempt_send(recipients, msg_strings) 173 | 174 | def _attempt_send(self, recipients, msg_strings): 175 | attempts = 0 176 | while attempts < 3: 177 | try: 178 | result = self.smtp.sendmail(self.user, recipients, msg_strings) 179 | self.log.info("Message sent to %s", recipients) 180 | self.num_mail_sent += 1 181 | return result 182 | except smtplib.SMTPServerDisconnected as e: 183 | self.log.error(e) 184 | attempts += 1 185 | time.sleep(attempts * 3) 186 | self.unsent.append((recipients, msg_strings)) 187 | return False 188 | 189 | def send_unsent(self): 190 | """ 191 | Emails that were not being able to send will be stored in :attr:`self.unsent`. 192 | Use this function to attempt to send these again 193 | """ 194 | for i in range(len(self.unsent)): 195 | recipients, msg_strings = self.unsent.pop(i) 196 | self._attempt_send(recipients, msg_strings) 197 | 198 | def close(self): 199 | """ Close the connection to the SMTP server """ 200 | self.is_closed = True 201 | try: 202 | self.smtp.quit() 203 | except (TypeError, AttributeError, smtplib.SMTPServerDisconnected): 204 | pass 205 | 206 | def login(self): 207 | if self.oauth2_file is not None: 208 | self._login_oauth2(self.credentials) 209 | else: 210 | self._login(self.credentials) 211 | 212 | def _login(self, password): 213 | """ 214 | Login to the SMTP server using password. `login` only needs to be manually run when the 215 | connection to the SMTP server was closed by the user. 216 | """ 217 | self.smtp = self.connection(self.host, self.port, **self.kwargs) 218 | self.smtp.set_debuglevel(self.debuglevel) 219 | if self.starttls: 220 | self.smtp.ehlo() 221 | if self.starttls is True: 222 | self.smtp.starttls() 223 | else: 224 | self.smtp.starttls(**self.starttls) 225 | self.smtp.ehlo() 226 | self.is_closed = False 227 | if not self.smtp_skip_login: 228 | password = self.handle_password(self.user, password) 229 | self.smtp.login(self.user, password) 230 | self.log.info("Connected to SMTP @ %s:%s as %s", self.host, self.port, self.user) 231 | 232 | @staticmethod 233 | def handle_password(user, password): 234 | return handle_password(user, password) 235 | 236 | @staticmethod 237 | def get_oauth_string(user, oauth2_info): 238 | return get_oauth_string(user, oauth2_info) 239 | 240 | def _login_oauth2(self, oauth2_info): 241 | if "email_address" in oauth2_info: 242 | oauth2_info.pop("email_address") 243 | self.smtp = self.connection(self.host, self.port, **self.kwargs) 244 | try: 245 | self.smtp.set_debuglevel(self.debuglevel) 246 | except AttributeError: 247 | pass 248 | auth_string = self.get_oauth_string(self.user, oauth2_info) 249 | self.smtp.ehlo(oauth2_info["google_client_id"]) 250 | if self.starttls is True: 251 | self.smtp.starttls() 252 | self.smtp.docmd("AUTH", "XOAUTH2 " + auth_string) 253 | 254 | def feedback(self, message="Awesome features! You made my day! How can I contribute?"): 255 | """ Most important function. Please send me feedback :-) """ 256 | self.send("kootenpv@gmail.com", "Yagmail feedback", message) 257 | 258 | def __del__(self): 259 | try: 260 | if not self.is_closed: 261 | self.close() 262 | except AttributeError: 263 | pass 264 | -------------------------------------------------------------------------------- /yagmail/sky.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kootenpv/yagmail/0591606f3eb87502a6a16c42c775f66380dd72c1/yagmail/sky.jpg -------------------------------------------------------------------------------- /yagmail/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class raw(str): 5 | """ Ensure that a string is treated as text and will not receive 'magic'. """ 6 | 7 | pass 8 | 9 | 10 | class inline(str): 11 | """ Only needed when wanting to inline an image rather than attach it """ 12 | 13 | pass 14 | 15 | 16 | def find_user_home_path(): 17 | with open(os.path.expanduser("~/.yagmail")) as f: 18 | return f.read().strip() 19 | -------------------------------------------------------------------------------- /yagmail/validate.py: -------------------------------------------------------------------------------- 1 | """ Module for validating emails. 2 | "Forked" only the regexp part from the "validate_email", see copyright below. 3 | The reason is that if you plan on sending out loads of emails or 4 | doing checks can actually get you blacklisted, if it would be reliable at all. 5 | However, this regexp is the best one I've come accross, so props to Syrus Akbary. 6 | """ 7 | 8 | # ----------------------------------------------------------------------------------------------- 9 | 10 | # RFC 2822 - style email validation for Python 11 | # (c) 2012 Syrus Akbary 12 | # Extended from (c) 2011 Noel Bush 13 | # for support of mx and user check 14 | # This code is made available to you under the GNU LGPL v3. 15 | # 16 | # This module provides a single method, valid_email_address(), 17 | # which returns True or False to indicate whether a given address 18 | # is valid according to the 'addr-spec' part of the specification 19 | # given in RFC 2822. Ideally, we would like to find this 20 | # in some other library, already thoroughly tested and well- 21 | # maintained. The standard Python library email.utils 22 | # contains a parse_addr() function, but it is not sufficient 23 | # to detect many malformed addresses. 24 | # 25 | # This implementation aims to be faithful to the RFC, with the 26 | # exception of a circular definition (see comments below), and 27 | # with the omission of the pattern components marked as "obsolete". 28 | 29 | import re 30 | 31 | 32 | try: 33 | from .error import YagInvalidEmailAddress 34 | except (ValueError, SystemError): 35 | # stupid fix to make it easy to load interactively 36 | from error import YagInvalidEmailAddress 37 | 38 | # All we are really doing is comparing the input string to one 39 | # gigantic regular expression. But building that regexp, and 40 | # ensuring its correctness, is made much easier by assembling it 41 | # from the "tokens" defined by the RFC. Each of these tokens is 42 | # tested in the accompanying unit test file. 43 | # 44 | # The section of RFC 2822 from which each pattern component is 45 | # derived is given in an accompanying comment. 46 | # 47 | # (To make things simple, every string below is given as 'raw', 48 | # even when it's not strictly necessary. This way we don't forget 49 | # when it is necessary.) 50 | # 51 | # see 2.2.2. Structured Header Field Bodies 52 | WSP = r'[ \t]' 53 | # see 2.2.3. Long Header Fields 54 | CRLF = r'(?:\r\n)' 55 | # see 3.2.1. Primitive Tokens 56 | NO_WS_CTL = r'\x01-\x08\x0b\x0c\x0f-\x1f\x7f' 57 | # see 3.2.2. Quoted characters 58 | QUOTED_PAIR = r'(?:\\.)' 59 | FWS = r'(?:(?:' + WSP + r'*' + CRLF + r')?' + \ 60 | WSP + \ 61 | r'+)' # see 3.2.3. Folding white space and comments 62 | CTEXT = r'[' + NO_WS_CTL + \ 63 | r'\x21-\x27\x2a-\x5b\x5d-\x7e]' # see 3.2.3 64 | CCONTENT = r'(?:' + CTEXT + r'|' + \ 65 | QUOTED_PAIR + \ 66 | r')' # see 3.2.3 (NB: The RFC includes COMMENT here 67 | # as well, but that would be circular.) 68 | COMMENT = r'\((?:' + FWS + r'?' + CCONTENT + \ 69 | r')*' + FWS + r'?\)' # see 3.2.3 70 | CFWS = r'(?:' + FWS + r'?' + COMMENT + ')*(?:' + \ 71 | FWS + '?' + COMMENT + '|' + FWS + ')' # see 3.2.3 72 | ATEXT = r'[\w!#$%&\'\*\+\-/=\?\^`\{\|\}~]' # see 3.2.4. Atom 73 | ATOM = CFWS + r'?' + ATEXT + r'+' + CFWS + r'?' # see 3.2.4 74 | DOT_ATOM_TEXT = ATEXT + r'+(?:\.' + ATEXT + r'+)*' # see 3.2.4 75 | DOT_ATOM = CFWS + r'?' + DOT_ATOM_TEXT + CFWS + r'?' # see 3.2.4 76 | QTEXT = r'[' + NO_WS_CTL + \ 77 | r'\x21\x23-\x5b\x5d-\x7e]' # see 3.2.5. Quoted strings 78 | QCONTENT = r'(?:' + QTEXT + r'|' + \ 79 | QUOTED_PAIR + r')' # see 3.2.5 80 | QUOTED_STRING = CFWS + r'?' + r'"(?:' + FWS + \ 81 | r'?' + QCONTENT + r')*' + FWS + \ 82 | r'?' + r'"' + CFWS + r'?' 83 | LOCAL_PART = r'(?:' + DOT_ATOM + r'|' + \ 84 | QUOTED_STRING + \ 85 | r')' # see 3.4.1. Addr-spec specification 86 | DTEXT = r'[' + NO_WS_CTL + r'\x21-\x5a\x5e-\x7e]' # see 3.4.1 87 | DCONTENT = r'(?:' + DTEXT + r'|' + \ 88 | QUOTED_PAIR + r')' # see 3.4.1 89 | DOMAIN_LITERAL = CFWS + r'?' + r'\[' + \ 90 | r'(?:' + FWS + r'?' + DCONTENT + \ 91 | r')*' + FWS + r'?\]' + CFWS + r'?' # see 3.4.1 92 | DOMAIN = r'(?:' + DOT_ATOM + r'|' + \ 93 | DOMAIN_LITERAL + r')' # see 3.4.1 94 | ADDR_SPEC = LOCAL_PART + r'@' + DOMAIN # see 3.4.1 95 | 96 | # A valid address will match exactly the 3.4.1 addr-spec. 97 | VALID_ADDRESS_REGEXP = '^' + ADDR_SPEC + '$' 98 | 99 | 100 | def validate_email_with_regex(email_address): 101 | """ 102 | Note that this will only filter out syntax mistakes in emailaddresses. 103 | If a human would think it is probably a valid email, it will most likely pass. 104 | However, it could still very well be that the actual emailaddress has simply 105 | not be claimed by anyone (so then this function fails to devalidate). 106 | """ 107 | if not re.match(VALID_ADDRESS_REGEXP, email_address): 108 | emsg = 'Emailaddress "{}" is not valid according to RFC 2822 standards'.format( 109 | email_address) 110 | raise YagInvalidEmailAddress(emsg) 111 | # apart from the standard, I personally do not trust email addresses without dot. 112 | if "." not in email_address and "localhost" not in email_address.lower(): 113 | raise YagInvalidEmailAddress("Missing dot in emailaddress") 114 | --------------------------------------------------------------------------------