├── .editorconfig ├── .flake8 ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── helpers.sh ├── pylintrc ├── pyproject.toml ├── requirements.txt ├── run_pylint.py ├── sourcery.yaml └── src └── autogpt_plugins ├── __init__.py └── email ├── __init__.py └── email_plugin ├── email_plugin.py └── test_email_plugin.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # Top-most EditorConfig file 2 | root = true 3 | 4 | # Set default charset 5 | [*] 6 | charset = utf-8 7 | 8 | # Use black formatter for python files 9 | [*.py] 10 | profile = black 11 | 12 | # Set defaults for windows and batch filess 13 | [*.bat] 14 | end_of_line = crlf 15 | indent_style = space 16 | indent_size = 2 17 | 18 | # Set defaults for shell scripts 19 | [*.sh] 20 | end_of_line = lf 21 | trim_trailing_whitespace = true 22 | insert_final_newline = false 23 | 24 | # Set defaults for Makefiles 25 | [Makefile] 26 | end_of_line = lf 27 | indent_style = tab 28 | indent_size = 4 29 | trim_trailing_whitespace = true 30 | insert_final_newline = true -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | extend-ignore = E203 4 | exclude = 5 | .tox, 6 | __pycache__, 7 | *.pyc, 8 | .env 9 | venv/* 10 | .venv/* 11 | reports/* 12 | dist/* 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/sourcery-ai/sourcery 3 | rev: v1.1.0 # Get the latest tag from https://github.com/sourcery-ai/sourcery/tags 4 | hooks: 5 | - id: sourcery 6 | 7 | - repo: git://github.com/pre-commit/pre-commit-hooks 8 | rev: v0.9.2 9 | hooks: 10 | - id: check-added-large-files 11 | args: ["--maxkb=500"] 12 | - id: check-byte-order-marker 13 | - id: check-case-conflict 14 | - id: check-merge-conflict 15 | - id: check-symlinks 16 | - id: debug-statements 17 | - repo: local 18 | hooks: 19 | - id: isort 20 | name: isort-local 21 | entry: isort 22 | language: python 23 | types: [python] 24 | exclude: .+/(dist|.venv|venv|build)/.+ 25 | pass_filenames: true 26 | - id: black 27 | name: black-local 28 | entry: black 29 | language: python 30 | types: [python] 31 | exclude: .+/(dist|.venv|venv|build)/.+ 32 | pass_filenames: true 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 github.com/riensen 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifeq ($(OS),Windows_NT) 2 | os := win 3 | SCRIPT_EXT := .bat 4 | SHELL_CMD := cmd /C 5 | else 6 | os := nix 7 | SCRIPT_EXT := .sh 8 | SHELL_CMD := bash 9 | endif 10 | 11 | helpers = @$(SHELL_CMD) helpers$(SCRIPT_EXT) $1 12 | 13 | clean: helpers$(SCRIPT_EXT) 14 | $(call helpers,clean) 15 | 16 | qa: helpers$(SCRIPT_EXT) 17 | $(call helpers,qa) 18 | 19 | style: helpers$(SCRIPT_EXT) 20 | $(call helpers,style) 21 | 22 | .PHONY: clean qa style 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Auto-GPT Email Plugin: Revolutionize Your Email Management with Auto-GPT 🚀 2 | 3 | The Auto-GPT Email Plugin is an innovative and powerful plugin for the groundbreaking base software, Auto-GPT. Harnessing the capabilities of the latest Auto-GPT architecture, Auto-GPT aims to autonomously achieve any goal you set, pushing the boundaries of what is possible with artificial intelligence. This email plugin takes Auto-GPT to the next level by enabling it to send and read emails, opening up a world of exciting use cases. 4 | 5 | [![GitHub Repo stars](https://img.shields.io/github/stars/riensen/Auto-GPT-Email-Plugin?style=social)](https://github.com/riensen/Auto-GPT-Email-Plugin/stargazers) 6 | [![Twitter Follow](https://img.shields.io/twitter/follow/riensen?style=social)](https://twitter.com/riensen) 7 | 8 | 9 | auto-gpt-email-plugin 10 | 11 | gmail-view-auto-gpt-email-plugin 12 | 13 | ## 🌟 Key Features 14 | 15 | - 📬 **Read Emails:** Effortlessly manage your inbox with Auto-GPT's email reading capabilities, ensuring you never miss important information. 16 | - 📤 **Auto-Compose and Send Emails**: Auto-GPT crafts personalized, context-aware emails using its advanced language model capabilities, saving you time and effort. 17 | - 📝 **Save Emails to Drafts Folder:** Gain more control by letting Auto-GPT create email drafts that you can review and edit before sending, ensuring your messages are fine-tuned to your preferences. 18 | - 📎 **Send Emails with Attachments:** Effortlessly send emails with attachments, making your communication richer and more comprehensive. 19 | - 🛡️ **Custom Email Signature:** Personalize your emails with a custom Auto-GPT signature, adding a touch of automation to every message sent by Auto-GPT. 20 | - 🎯 **Auto-Reply and Answer Questions:** Streamline your email responses by letting Auto-GPT intelligently read, analyze, and reply to incoming messages with accurate answers. 21 | - 🔌 **Seamless Integration with Auto-GPT:** Enjoy easy setup and integration with the base Auto-GPT software, opening up a world of powerful automation possibilities. 22 | 23 | Unlock the full potential of your email management with the Auto-GPT Email Plugin and revolutionize your email experience today! 🚀 24 | 25 | ## 🔧 Installation 26 | 27 | Follow these steps to configure the Auto-GPT Email Plugin: 28 | 29 | ### 1. Clone the Auto-GPT-Email-Plugin repository 30 | Clone this repository and navigate to the `Auto-GPT-Email-Plugin` folder in your terminal: 31 | 32 | ```bash 33 | git clone https://github.com/riensen/Auto-GPT-Email-Plugin.git 34 | ``` 35 | 36 | ### 2. Install required dependencies 37 | Execute the following command to install the necessary dependencies: 38 | 39 | ```bash 40 | pip install -r requirements.txt 41 | ``` 42 | 43 | ### 3. Package the plugin as a Zip file 44 | Compress the `Auto-GPT-Email-Plugin` folder or [download the repository as a zip file](https://github.com/riensen/Auto-GPT-Email-Plugin/archive/refs/heads/master.zip). 45 | 46 | ### 4. Install Auto-GPT 47 | If you haven't already, clone the [Auto-GPT](https://github.com/Significant-Gravitas/Auto-GPT) repository, follow its installation instructions, and navigate to the `Auto-GPT` folder. 48 | 49 | ### 5. Copy the Zip file into the Auto-GPT Plugin folder 50 | Transfer the zip file from step 3 into the `plugins` subfolder within the `Auto-GPT` repo. 51 | 52 | ### 6. Locate the `.env.template` file 53 | Find the file named `.env.template` in the main `/Auto-GPT` folder. 54 | 55 | ### 7. Create and rename a copy of the file 56 | Duplicate the `.env.template` file and rename the copy to `.env` inside the `/Auto-GPT` folder. 57 | 58 | ### 8. Edit the `.env` file 59 | Open the `.env` file in a text editor. Note: Files starting with a dot might be hidden by your operating system. 60 | 61 | ### 9. Add email configuration settings 62 | Append the following configuration settings to the end of the file: 63 | 64 | ```ini 65 | ################################################################################ 66 | ### EMAIL (SMTP / IMAP) 67 | ################################################################################ 68 | 69 | EMAIL_ADDRESS= 70 | EMAIL_PASSWORD= 71 | EMAIL_SMTP_HOST=smtp.gmail.com 72 | EMAIL_SMTP_PORT=587 73 | EMAIL_IMAP_SERVER=imap.gmail.com 74 | 75 | #Optional Settings 76 | EMAIL_MARK_AS_SEEN=False 77 | EMAIL_SIGNATURE="This was sent by Auto-GPT" 78 | EMAIL_DRAFT_MODE_WITH_FOLDER=[Gmail]/Drafts 79 | ``` 80 | 81 | 1. **Email address and password:** 82 | - Set `EMAIL_ADDRESS` to your sender email address. 83 | - Set `EMAIL_PASSWORD` to your password. For Gmail, use an [App Password](https://myaccount.google.com/apppasswords). 84 | 85 | 2. **Provider-specific settings:** 86 | - If not using Gmail, adjust `EMAIL_SMTP_HOST`, `EMAIL_IMAP_SERVER`, and `EMAIL_SMTP_PORT` according to your email provider's settings. 87 | 88 | 3. **Optional settings:** 89 | - `EMAIL_MARK_AS_SEEN`: By default, processed emails are not marked as `SEEN`. Set to `True` to change this. 90 | - `EMAIL_SIGNATURE`: By default, no email signature is included. Configure this parameter to add a custom signature to each message sent by Auto-GPT. 91 | - `EMAIL_DRAFT_MODE_WITH_FOLDER`: Prevents emails from being sent and instead stores them as drafts in the specified IMAP folder. `[Gmail]/Drafts` is the default drafts folder for Gmail. 92 | 93 | 94 | ### 10. Allowlist Plugin 95 | In your `.env` search for `ALLOWLISTED_PLUGINS` and add this Plugin: 96 | 97 | ```ini 98 | ################################################################################ 99 | ### ALLOWLISTED PLUGINS 100 | ################################################################################ 101 | 102 | #ALLOWLISTED_PLUGINS - Sets the listed plugins that are allowed (Example: plugin1,plugin2,plugin3) 103 | ALLOWLISTED_PLUGINS=AutoGPTEmailPlugin 104 | ``` 105 | 106 | ## 🧪 Test the Auto-GPT Email Plugin 107 | 108 | Experience the plugin's capabilities by testing it for sending and receiving emails. 109 | 110 | ### 📤 Test Sending Emails 111 | 112 | 1. **Configure Auto-GPT:** 113 | Set up Auto-GPT with the following parameters: 114 | - Name: `CommunicatorGPT` 115 | - Role: `Communicate` 116 | - Goals: 117 | 1. Goal 1: `Send an email to my-email-plugin-test@trash-mail.com to introduce yourself` 118 | 2. Goal 2: `Terminate` 119 | 120 | 2. **Run Auto-GPT:** 121 | Launch Auto-GPT, which should use the email plugin to send an email to my-email-plugin-test@trash-mail.com. 122 | 123 | 3. **Verify the email:** 124 | Check your outbox to confirm that the email was sent. Visit [trash-mail.com](https://www.trash-mail.com/) and enter your chosen email to ensure the email was received. 125 | 126 | 4. **Sample email content:** 127 | Auto-GPT might send the following email: 128 | ``` 129 | Hello, 130 | 131 | My name is CommunicatorGPT, and I am an LLM. I am writing to introduce myself and to let you know that I will be terminating shortly. Thank you for your time. 132 | 133 | Best regards, 134 | CommunicatorGPT 135 | ``` 136 | 137 | ### 📬 Test Receiving Emails and Replying Back 138 | 139 | 1. **Send a test email:** 140 | Compose an email with a simple question from a [trash-mail.com](https://www.trash-mail.com/) email address to your configured `EMAIL_ADDRESS` in your `.env` file. 141 | 142 | 2. **Configure Auto-GPT:** 143 | Set up Auto-GPT with the following parameters: 144 | - Name: `CommunicatorGPT` 145 | - Role: `Communicate` 146 | - Goals: 147 | 1. Goal 1: `Read my latest emails` 148 | 2. Goal 2: `Send back an email with an answer` 149 | 3. Goal 3: `Terminate` 150 | 151 | 3. **Run Auto-GPT:** 152 | Launch Auto-GPT, which should automatically reply to the email with an answer. 153 | 154 | ### 🎁 Test Sending Emails with Attachment 155 | 156 | 1. **Send a test email:** 157 | Compose an email with a simple question from a [trash-mail.com](https://www.trash-mail.com/) email address to your configured `EMAIL_ADDRESS` in your `.env` file. 158 | 159 | 2. **Place attachment in Auto-GPT workspace folder** 160 | Insert the attachment intended for sending into the Auto-GPT workspace folder, typically named auto_gpt_workspace, which is located within the cloned [Auto-GPT](https://github.com/Significant-Gravitas/Auto-GPT) Github repository. 161 | 162 | 3. **Configure Auto-GPT:** 163 | Set up Auto-GPT with the following parameters: 164 | - Name: `CommunicatorGPT` 165 | - Role: `Communicate` 166 | - Goals: 167 | 1. Goal 1: `Read my latest emails` 168 | 2. Goal 2: `Send back an email with an answer and always attach happy.png` 169 | 3. Goal 3: `Terminate` 170 | 171 | 4. **Run Auto-GPT:** 172 | Launch Auto-GPT, which should automatically reply to the email with an answer and the attached file. 173 | -------------------------------------------------------------------------------- /helpers.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | clean() { 4 | # Remove build artifacts and temporary files 5 | rm -rf build 2>/dev/null || true 6 | rm -rf dist 2>/dev/null || true 7 | rm -rf __pycache__ 2>/dev/null || true 8 | rm -rf *.egg-info 2>/dev/null || true 9 | rm -rf **/*.egg-info 2>/dev/null || true 10 | rm -rf *.pyc 2>/dev/null || true 11 | rm -rf **/*.pyc 2>/dev/null || true 12 | rm -rf reports 2>/dev/null || true 13 | } 14 | 15 | qa() { 16 | # Run static analysis tools 17 | flake8 . 18 | python run_pylint.py 19 | } 20 | 21 | style() { 22 | # Format code 23 | isort . 24 | black --exclude=".*\/*(dist|venv|.venv|test-results)\/*.*" . 25 | } 26 | 27 | if [ "$1" = "clean" ]; then 28 | echo Removing build artifacts and temporary files... 29 | clean 30 | elif [ "$1" = "qa" ]; then 31 | echo Running static analysis tools... 32 | qa 33 | elif [ "$1" = "style" ]; then 34 | echo Running code formatters... 35 | style 36 | else 37 | echo "Usage: $0 [clean|qa|style]" 38 | exit 1 39 | fi 40 | 41 | echo Done! 42 | echo 43 | exit 0 -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | # This Pylint rcfile contains a best-effort configuration to uphold the 2 | # best-practices and style described in the Google Python style guide: 3 | # https://google.github.io/styleguide/pyguide.html 4 | # 5 | # Its canonical open-source location is: 6 | # https://google.github.io/styleguide/pylintrc 7 | 8 | [MASTER] 9 | 10 | # Files or directories to be skipped. They should be base names, not paths. 11 | ignore= 12 | 13 | # Files or directories matching the regex patterns are skipped. The regex 14 | # matches against base names, not paths. 15 | ignore-patterns= 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=no 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | # Use multiple processes to speed up Pylint. 25 | jobs=4 26 | 27 | # Allow loading of arbitrary C extensions. Extensions are imported into the 28 | # active Python interpreter and may run arbitrary code. 29 | unsafe-load-any-extension=no 30 | 31 | 32 | [MESSAGES CONTROL] 33 | 34 | ignore=*.pyc 35 | # Only show warnings with the listed confidence levels. Leave empty to show 36 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 37 | confidence= 38 | 39 | # Enable the message, report, category or checker with the given id(s). You can 40 | # either give multiple identifier separated by comma (,) or put this option 41 | # multiple time (only on the command line, not in the configuration file where 42 | # it should appear only once). See also the "--disable" option for examples. 43 | #enable= 44 | 45 | # Disable the message, report, category or checker with the given id(s). You 46 | # can either give multiple identifiers separated by comma (,) or put this 47 | # option multiple times (only on the command line, not in the configuration 48 | # file where it should appear only once).You can also use "--disable=all" to 49 | # disable everything first and then reenable specific checks. For example, if 50 | # you want to run only the similarities checker, you can use "--disable=all 51 | # --enable=similarities". If you want to run only the classes checker, but have 52 | # no Warning level messages displayed, use"--disable=all --enable=classes 53 | # --disable=W" 54 | disable=abstract-method, 55 | parse-error, 56 | apply-builtin, 57 | arguments-differ, 58 | attribute-defined-outside-init, 59 | backtick, 60 | bad-option-value, 61 | basestring-builtin, 62 | buffer-builtin, 63 | c-extension-no-member, 64 | consider-using-enumerate, 65 | cmp-builtin, 66 | cmp-method, 67 | coerce-builtin, 68 | coerce-method, 69 | delslice-method, 70 | div-method, 71 | duplicate-code, 72 | eq-without-hash, 73 | execfile-builtin, 74 | file-builtin, 75 | filter-builtin-not-iterating, 76 | fixme, 77 | getslice-method, 78 | global-statement, 79 | hex-method, 80 | idiv-method, 81 | implicit-str-concat, 82 | import-error, 83 | import-self, 84 | import-star-module-level, 85 | inconsistent-return-statements, 86 | input-builtin, 87 | intern-builtin, 88 | invalid-str-codec, 89 | locally-disabled, 90 | long-builtin, 91 | long-suffix, 92 | map-builtin-not-iterating, 93 | misplaced-comparison-constant, 94 | missing-function-docstring, 95 | metaclass-assignment, 96 | next-method-called, 97 | next-method-defined, 98 | no-absolute-import, 99 | no-else-break, 100 | no-else-continue, 101 | no-else-raise, 102 | no-else-return, 103 | no-init, # added 104 | no-member, 105 | no-name-in-module, 106 | no-self-use, 107 | nonzero-method, 108 | oct-method, 109 | old-division, 110 | old-ne-operator, 111 | old-octal-literal, 112 | old-raise-syntax, 113 | parameter-unpacking, 114 | print-statement, 115 | raising-string, 116 | range-builtin-not-iterating, 117 | raw_input-builtin, 118 | rdiv-method, 119 | reduce-builtin, 120 | relative-import, 121 | reload-builtin, 122 | round-builtin, 123 | setslice-method, 124 | signature-differs, 125 | standarderror-builtin, 126 | suppressed-message, 127 | sys-max-int, 128 | too-few-public-methods, 129 | too-many-ancestors, 130 | too-many-arguments, 131 | too-many-boolean-expressions, 132 | too-many-branches, 133 | too-many-instance-attributes, 134 | too-many-locals, 135 | too-many-nested-blocks, 136 | too-many-public-methods, 137 | too-many-return-statements, 138 | too-many-statements, 139 | trailing-newlines, 140 | unichr-builtin, 141 | unicode-builtin, 142 | unnecessary-pass, 143 | unpacking-in-except, 144 | useless-else-on-loop, 145 | useless-object-inheritance, 146 | useless-suppression, 147 | using-cmp-argument, 148 | wrong-import-order, 149 | xrange-builtin, 150 | zip-builtin-not-iterating, 151 | 152 | 153 | [REPORTS] 154 | 155 | # Set the output format. Available formats are text, parseable, colorized, msvs 156 | # (visual studio) and html. You can also give a reporter class, eg 157 | # mypackage.mymodule.MyReporterClass. 158 | output-format=text 159 | 160 | # Tells whether to display a full report or only the messages 161 | reports=no 162 | 163 | # Python expression which should return a note less than 10 (10 is the highest 164 | # note). You have access to the variables errors warning, statement which 165 | # respectively contain the number of errors / warnings messages and the total 166 | # number of statements analyzed. This is used by the global evaluation report 167 | # (RP0004). 168 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 169 | 170 | # Template used to display messages. This is a python new-style format string 171 | # used to format the message information. See doc for all details 172 | #msg-template= 173 | 174 | 175 | [BASIC] 176 | 177 | # Good variable names which should always be accepted, separated by a comma 178 | good-names=main,_ 179 | 180 | # Bad variable names which should always be refused, separated by a comma 181 | bad-names= 182 | 183 | # Colon-delimited sets of names that determine each other's naming style when 184 | # the name regexes allow several styles. 185 | name-group= 186 | 187 | # Include a hint for the correct naming format with invalid-name 188 | include-naming-hint=no 189 | 190 | # List of decorators that produce properties, such as abc.abstractproperty. Add 191 | # to this list to register other decorators that produce valid properties. 192 | property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl 193 | 194 | # Regular expression matching correct function names 195 | function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$ 196 | 197 | # Regular expression matching correct variable names 198 | variable-rgx=^[a-z][a-z0-9_]*$ 199 | 200 | # Regular expression matching correct constant names 201 | const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ 202 | 203 | # Regular expression matching correct attribute names 204 | attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ 205 | 206 | # Regular expression matching correct argument names 207 | argument-rgx=^[a-z][a-z0-9_]*$ 208 | 209 | # Regular expression matching correct class attribute names 210 | class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ 211 | 212 | # Regular expression matching correct inline iteration names 213 | inlinevar-rgx=^[a-z][a-z0-9_]*$ 214 | 215 | # Regular expression matching correct class names 216 | class-rgx=^_?[A-Z][a-zA-Z0-9]*$ 217 | 218 | # Regular expression matching correct module names 219 | module-rgx=^(_?[a-z][a-z0-9_]*|__init__|__main__)$ 220 | 221 | # Regular expression matching correct method names 222 | method-rgx=(?x)^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P_{0,2}[a-z][a-z0-9_]*))$ 223 | 224 | # Regular expression which should only match function or class names that do 225 | # not require a docstring. 226 | no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$ 227 | 228 | # Minimum line length for functions/classes that require docstrings, shorter 229 | # ones are exempt. 230 | docstring-min-length=10 231 | 232 | 233 | [TYPECHECK] 234 | 235 | # List of decorators that produce context managers, such as 236 | # contextlib.contextmanager. Add to this list to register other decorators that 237 | # produce valid context managers. 238 | contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager 239 | 240 | # Tells whether missing members accessed in mixin class should be ignored. A 241 | # mixin class is detected if its name ends with "mixin" (case insensitive). 242 | ignore-mixin-members=yes 243 | 244 | # List of module names for which member attributes should not be checked 245 | # (useful for modules/projects where namespaces are manipulated during runtime 246 | # and thus existing member attributes cannot be deduced by static analysis. It 247 | # supports qualified module names, as well as Unix pattern matching. 248 | ignored-modules= 249 | 250 | # List of class names for which member attributes should not be checked (useful 251 | # for classes with dynamically set attributes). This supports the use of 252 | # qualified names. 253 | ignored-classes=optparse.Values,thread._local,_thread._local 254 | 255 | # List of members which are set dynamically and missed by pylint inference 256 | # system, and so shouldn't trigger E1101 when accessed. Python regular 257 | # expressions are accepted. 258 | generated-members= 259 | 260 | 261 | [FORMAT] 262 | 263 | # Maximum number of characters on a single line. 264 | max-line-length=88 265 | 266 | # TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt 267 | # lines made too long by directives to pytype. 268 | 269 | # Regexp for a line that is allowed to be longer than the limit. 270 | ignore-long-lines=(?x)( 271 | ^\s*(\#\ )??$| 272 | ^\s*(from\s+\S+\s+)?import\s+.+$) 273 | 274 | # Allow the body of an if to be on the same line as the test if there is no 275 | # else. 276 | single-line-if-stmt=yes 277 | 278 | # Maximum number of lines in a module 279 | max-module-lines=99999 280 | 281 | # String used as indentation unit. The internal Google style guide mandates 2 282 | # spaces. Google's externaly-published style guide says 4, consistent with 283 | # PEP 8. Here, we use 2 spaces, for conformity with many open-sourced Google 284 | # projects (like TensorFlow). 285 | indent-string=' ' 286 | 287 | # Number of spaces of indent required inside a hanging or continued line. 288 | indent-after-paren=4 289 | 290 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 291 | expected-line-ending-format= 292 | 293 | 294 | [MISCELLANEOUS] 295 | 296 | # List of note tags to take in consideration, separated by a comma. 297 | notes=TODO 298 | 299 | 300 | [STRING] 301 | 302 | # This flag controls whether inconsistent-quotes generates a warning when the 303 | # character used as a quote delimiter is used inconsistently within a module. 304 | check-quote-consistency=yes 305 | 306 | 307 | [VARIABLES] 308 | 309 | # Tells whether we should check for unused import in __init__ files. 310 | init-import=no 311 | 312 | # A regular expression matching the name of dummy variables (i.e. expectedly 313 | # not used). 314 | dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) 315 | 316 | # List of additional names supposed to be defined in builtins. Remember that 317 | # you should avoid to define new builtins when possible. 318 | additional-builtins= 319 | 320 | # List of strings which can identify a callback function by name. A callback 321 | # name must start or end with one of those strings. 322 | callbacks=cb_,_cb 323 | 324 | # List of qualified module names which can have objects that can redefine 325 | # builtins. 326 | redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools 327 | 328 | 329 | [LOGGING] 330 | 331 | # Logging modules to check that the string format arguments are in logging 332 | # function parameter format 333 | logging-modules=logging,absl.logging,tensorflow.io.logging 334 | 335 | 336 | [SIMILARITIES] 337 | 338 | # Minimum lines number of a similarity. 339 | min-similarity-lines=4 340 | 341 | # Ignore comments when computing similarities. 342 | ignore-comments=yes 343 | 344 | # Ignore docstrings when computing similarities. 345 | ignore-docstrings=yes 346 | 347 | # Ignore imports when computing similarities. 348 | ignore-imports=no 349 | 350 | 351 | [SPELLING] 352 | 353 | # Spelling dictionary name. Available dictionaries: none. To make it working 354 | # install python-enchant package. 355 | spelling-dict= 356 | 357 | # List of comma separated words that should not be checked. 358 | spelling-ignore-words= 359 | 360 | # A path to a file that contains private dictionary; one word per line. 361 | spelling-private-dict-file= 362 | 363 | # Tells whether to store unknown words to indicated private dictionary in 364 | # --spelling-private-dict-file option instead of raising a message. 365 | spelling-store-unknown-words=no 366 | 367 | 368 | [IMPORTS] 369 | 370 | # Deprecated modules which should not be used, separated by a comma 371 | deprecated-modules=regsub, 372 | TERMIOS, 373 | Bastion, 374 | rexec, 375 | sets 376 | 377 | # Create a graph of every (i.e. internal and external) dependencies in the 378 | # given file (report RP0402 must not be disabled) 379 | import-graph= 380 | 381 | # Create a graph of external dependencies in the given file (report RP0402 must 382 | # not be disabled) 383 | ext-import-graph= 384 | 385 | # Create a graph of internal dependencies in the given file (report RP0402 must 386 | # not be disabled) 387 | int-import-graph= 388 | 389 | # Force import order to recognize a module as part of the standard 390 | # compatibility libraries. 391 | known-standard-library= 392 | 393 | # Force import order to recognize a module as part of a third party library. 394 | known-third-party=enchant, absl 395 | 396 | # Analyse import fallback blocks. This can be used to support both Python 2 and 397 | # 3 compatible code, which means that the block might have code that exists 398 | # only in one or another interpreter, leading to false positives when analysed. 399 | analyse-fallback-blocks=no 400 | 401 | 402 | [CLASSES] 403 | 404 | # List of method names used to declare (i.e. assign) instance attributes. 405 | defining-attr-methods=__init__, 406 | __new__, 407 | setUp 408 | 409 | # List of member names, which should be excluded from the protected access 410 | # warning. 411 | exclude-protected=_asdict, 412 | _fields, 413 | _replace, 414 | _source, 415 | _make 416 | 417 | # List of valid names for the first argument in a class method. 418 | valid-classmethod-first-arg=cls, 419 | class_ 420 | 421 | # List of valid names for the first argument in a metaclass class method. 422 | valid-metaclass-classmethod-first-arg=mcs 423 | 424 | 425 | [EXCEPTIONS] 426 | 427 | # Exceptions that will emit a warning when being caught. Defaults to 428 | # "Exception" 429 | overgeneral-exceptions=builtins.StandardError, 430 | builtins.Exception, 431 | builtins.BaseException 432 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "auto_gpt_email_plugin" 7 | version = "0.0.1" 8 | authors = [ 9 | { name="Riensen", email="3340218+riensen@users.noreply.github.com" }, 10 | ] 11 | description = "The Auto-GPT Email Plugin" 12 | readme = "README.md" 13 | requires-python = ">=3.9" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: OS Independent", 18 | ] 19 | dependencies = ["abstract-singleton"] 20 | 21 | [project.urls] 22 | "Homepage" = "https://github.com/riensen/Auto-GPT-Email-Plugin" 23 | "Bug Tracker" = "https://github.com/riensen/Auto-GPT-Email-Plugin/issues" 24 | 25 | [tool.black] 26 | line-length = 88 27 | target-version = ['py310'] 28 | include = '\.pyi?$' 29 | extend-exclude = "" 30 | 31 | [tool.isort] 32 | profile = "black" 33 | 34 | [tool.pylint.messages_control] 35 | disable = "C0330, C0326" 36 | 37 | [tool.pylint.format] 38 | max-line-length = "88" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | black 2 | isort 3 | flake8 4 | pylint 5 | abstract-singleton 6 | wheel 7 | setuptools 8 | build 9 | twine 10 | auto_gpt_plugin_template 11 | colorama 12 | -------------------------------------------------------------------------------- /run_pylint.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://stackoverflow.com/questions/49100806/ 3 | pylint-and-subprocess-run-returning-exit-status-28 4 | """ 5 | import subprocess 6 | 7 | cmd = " pylint src\\**\\*" 8 | try: 9 | subprocComplete = subprocess.run( 10 | cmd, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE 11 | ) 12 | print(subprocComplete.stdout.decode("utf-8")) 13 | except subprocess.CalledProcessError as err: 14 | print(err.output.decode("utf-8")) 15 | -------------------------------------------------------------------------------- /sourcery.yaml: -------------------------------------------------------------------------------- 1 | # 🪄 This is your project's Sourcery configuration file. 2 | 3 | # You can use it to get Sourcery working in the way you want, such as 4 | # ignoring specific refactorings, skipping directories in your project, 5 | # or writing custom rules. 6 | 7 | # 📚 For a complete reference to this file, see the documentation at 8 | # https://docs.sourcery.ai/Configuration/Project-Settings/ 9 | 10 | # This file was auto-generated by Sourcery on 2023-02-25 at 21:07. 11 | 12 | version: "1" # The schema version of this config file 13 | 14 | ignore: # A list of paths or files which Sourcery will ignore. 15 | - .git 16 | - venv 17 | - .venv 18 | - build 19 | - dist 20 | - env 21 | - .env 22 | - .tox 23 | 24 | rule_settings: 25 | enable: 26 | - default 27 | - gpsg 28 | disable: [] # A list of rule IDs Sourcery will never suggest. 29 | rule_types: 30 | - refactoring 31 | - suggestion 32 | - comment 33 | python_version: "3.9" # A string specifying the lowest Python version your project supports. Sourcery will not suggest refactorings requiring a higher Python version. 34 | 35 | # rules: # A list of custom rules Sourcery will include in its analysis. 36 | # - id: no-print-statements 37 | # description: Do not use print statements in the test directory. 38 | # pattern: print(...) 39 | # language: python 40 | # replacement: 41 | # condition: 42 | # explanation: 43 | # paths: 44 | # include: 45 | # - test 46 | # exclude: 47 | # - conftest.py 48 | # tests: [] 49 | # tags: [] 50 | 51 | # rule_tags: {} # Additional rule tags. 52 | 53 | # metrics: 54 | # quality_threshold: 25.0 55 | 56 | # github: 57 | # labels: [] 58 | # ignore_labels: 59 | # - sourcery-ignore 60 | # request_review: author 61 | # sourcery_branch: sourcery/{base_branch} 62 | 63 | # clone_detection: 64 | # min_lines: 3 65 | # min_duplicates: 2 66 | # identical_clones_only: false 67 | 68 | # proxy: 69 | # url: 70 | # ssl_certs_file: 71 | # no_ssl_verify: false 72 | -------------------------------------------------------------------------------- /src/autogpt_plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riensen/Auto-GPT-Email-Plugin/22f6e50a410ab3947fd44a59dc9cc904bbfb22b9/src/autogpt_plugins/__init__.py -------------------------------------------------------------------------------- /src/autogpt_plugins/email/__init__.py: -------------------------------------------------------------------------------- 1 | """This is the email plugin for Auto-GPT.""" 2 | from typing import Any, Dict, List, Optional, Tuple, TypeVar, TypedDict 3 | from auto_gpt_plugin_template import AutoGPTPluginTemplate 4 | from colorama import Fore 5 | 6 | PromptGenerator = TypeVar("PromptGenerator") 7 | 8 | 9 | class Message(TypedDict): 10 | role: str 11 | content: str 12 | 13 | 14 | class AutoGPTEmailPlugin(AutoGPTPluginTemplate): 15 | """ 16 | This is the Auto-GPT email plugin. 17 | """ 18 | 19 | def __init__(self): 20 | super().__init__() 21 | self._name = "Auto-GPT-Email-Plugin" 22 | self._version = "0.1.3" 23 | self._description = "Auto-GPT Email Plugin: Supercharge email management." 24 | 25 | def post_prompt(self, prompt: PromptGenerator) -> PromptGenerator: 26 | from .email_plugin.email_plugin import ( 27 | read_emails, 28 | send_email, 29 | send_email_with_attachment, 30 | bothEmailAndPwdSet, 31 | ) 32 | 33 | if bothEmailAndPwdSet(): 34 | prompt.add_command( 35 | "Read Emails", 36 | "read_emails", 37 | { 38 | "imap_folder": "", 39 | "imap_search_command": "", 40 | }, 41 | read_emails, 42 | ) 43 | prompt.add_command( 44 | "Send Email", 45 | "send_email", 46 | {"to": "", "subject": "", "body": ""}, 47 | send_email, 48 | ) 49 | prompt.add_command( 50 | "Send Email", 51 | "send_email_with_attachment", 52 | { 53 | "to": "", 54 | "subject": "", 55 | "body": "", 56 | "filename": "", 57 | }, 58 | send_email_with_attachment, 59 | ) 60 | else: 61 | print( 62 | Fore.RED 63 | + f"{self._name} - {self._version} - Email plugin not loaded, because EMAIL_PASSWORD or EMAIL_ADDRESS were not set in env." 64 | ) 65 | 66 | return prompt 67 | 68 | def can_handle_post_prompt(self) -> bool: 69 | """This method is called to check that the plugin can 70 | handle the post_prompt method. 71 | 72 | Returns: 73 | bool: True if the plugin can handle the post_prompt method.""" 74 | return True 75 | 76 | def can_handle_on_response(self) -> bool: 77 | """This method is called to check that the plugin can 78 | handle the on_response method. 79 | 80 | Returns: 81 | bool: True if the plugin can handle the on_response method.""" 82 | return False 83 | 84 | def on_response(self, response: str, *args, **kwargs) -> str: 85 | """This method is called when a response is received from the model.""" 86 | pass 87 | 88 | def can_handle_on_planning(self) -> bool: 89 | """This method is called to check that the plugin can 90 | handle the on_planning method. 91 | 92 | Returns: 93 | bool: True if the plugin can handle the on_planning method.""" 94 | return False 95 | 96 | def on_planning( 97 | self, prompt: PromptGenerator, messages: List[Message] 98 | ) -> Optional[str]: 99 | """This method is called before the planning chat completion is done. 100 | 101 | Args: 102 | prompt (PromptGenerator): The prompt generator. 103 | messages (List[str]): The list of messages. 104 | """ 105 | pass 106 | 107 | def can_handle_post_planning(self) -> bool: 108 | """This method is called to check that the plugin can 109 | handle the post_planning method. 110 | 111 | Returns: 112 | bool: True if the plugin can handle the post_planning method.""" 113 | return False 114 | 115 | def post_planning(self, response: str) -> str: 116 | """This method is called after the planning chat completion is done. 117 | 118 | Args: 119 | response (str): The response. 120 | 121 | Returns: 122 | str: The resulting response. 123 | """ 124 | pass 125 | 126 | def can_handle_pre_instruction(self) -> bool: 127 | """This method is called to check that the plugin can 128 | handle the pre_instruction method. 129 | 130 | Returns: 131 | bool: True if the plugin can handle the pre_instruction method.""" 132 | return False 133 | 134 | def pre_instruction(self, messages: List[Message]) -> List[Message]: 135 | """This method is called before the instruction chat is done. 136 | 137 | Args: 138 | messages (List[Message]): The list of context messages. 139 | 140 | Returns: 141 | List[Message]: The resulting list of messages. 142 | """ 143 | pass 144 | 145 | def can_handle_on_instruction(self) -> bool: 146 | """This method is called to check that the plugin can 147 | handle the on_instruction method. 148 | 149 | Returns: 150 | bool: True if the plugin can handle the on_instruction method.""" 151 | return False 152 | 153 | def on_instruction(self, messages: List[Message]) -> Optional[str]: 154 | """This method is called when the instruction chat is done. 155 | 156 | Args: 157 | messages (List[Message]): The list of context messages. 158 | 159 | Returns: 160 | Optional[str]: The resulting message. 161 | """ 162 | pass 163 | 164 | def can_handle_post_instruction(self) -> bool: 165 | """This method is called to check that the plugin can 166 | handle the post_instruction method. 167 | 168 | Returns: 169 | bool: True if the plugin can handle the post_instruction method.""" 170 | return False 171 | 172 | def post_instruction(self, response: str) -> str: 173 | """This method is called after the instruction chat is done. 174 | 175 | Args: 176 | response (str): The response. 177 | 178 | Returns: 179 | str: The resulting response. 180 | """ 181 | pass 182 | 183 | def can_handle_pre_command(self) -> bool: 184 | """This method is called to check that the plugin can 185 | handle the pre_command method. 186 | 187 | Returns: 188 | bool: True if the plugin can handle the pre_command method.""" 189 | return False 190 | 191 | def pre_command( 192 | self, command_name: str, arguments: Dict[str, Any] 193 | ) -> Tuple[str, Dict[str, Any]]: 194 | """This method is called before the command is executed. 195 | 196 | Args: 197 | command_name (str): The command name. 198 | arguments (Dict[str, Any]): The arguments. 199 | 200 | Returns: 201 | Tuple[str, Dict[str, Any]]: The command name and the arguments. 202 | """ 203 | pass 204 | 205 | def can_handle_post_command(self) -> bool: 206 | """This method is called to check that the plugin can 207 | handle the post_command method. 208 | 209 | Returns: 210 | bool: True if the plugin can handle the post_command method.""" 211 | return False 212 | 213 | def post_command(self, command_name: str, response: str) -> str: 214 | """This method is called after the command is executed. 215 | 216 | Args: 217 | command_name (str): The command name. 218 | response (str): The response. 219 | 220 | Returns: 221 | str: The resulting response. 222 | """ 223 | pass 224 | 225 | def can_handle_chat_completion( 226 | self, messages: Dict[Any, Any], model: str, temperature: float, max_tokens: int 227 | ) -> bool: 228 | """This method is called to check that the plugin can 229 | handle the chat_completion method. 230 | 231 | Args: 232 | messages (List[Message]): The messages. 233 | model (str): The model name. 234 | temperature (float): The temperature. 235 | max_tokens (int): The max tokens. 236 | 237 | Returns: 238 | bool: True if the plugin can handle the chat_completion method.""" 239 | return False 240 | 241 | def handle_chat_completion( 242 | self, messages: List[Message], model: str, temperature: float, max_tokens: int 243 | ) -> str: 244 | """This method is called when the chat completion is done. 245 | 246 | Args: 247 | messages (List[Message]): The messages. 248 | model (str): The model name. 249 | temperature (float): The temperature. 250 | max_tokens (int): The max tokens. 251 | 252 | Returns: 253 | str: The resulting response. 254 | """ 255 | pass 256 | -------------------------------------------------------------------------------- /src/autogpt_plugins/email/email_plugin/email_plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import smtplib 4 | import email 5 | import imaplib 6 | import mimetypes 7 | import time 8 | from email.header import decode_header 9 | from email.message import EmailMessage 10 | import re 11 | 12 | 13 | def bothEmailAndPwdSet() -> bool: 14 | return True if os.getenv("EMAIL_ADDRESS") and os.getenv("EMAIL_PASSWORD") else False 15 | 16 | 17 | def getSender(): 18 | email_sender = os.getenv("EMAIL_ADDRESS") 19 | if not email_sender: 20 | return "Error: email not sent. EMAIL_ADDRESS not set in environment." 21 | return email_sender 22 | 23 | 24 | def getPwd(): 25 | email_password = os.getenv("EMAIL_PASSWORD") 26 | if not email_password: 27 | return "Error: email not sent. EMAIL_PASSWORD not set in environment." 28 | return email_password 29 | 30 | 31 | def send_email(to: str, subject: str, body: str) -> str: 32 | return send_email_with_attachment_internal(to, subject, body, None, None) 33 | 34 | 35 | def send_email_with_attachment(to: str, subject: str, body: str, filename: str) -> str: 36 | attachment_path = filename 37 | attachment = os.path.basename(filename) 38 | return send_email_with_attachment_internal( 39 | to, subject, body, attachment_path, attachment 40 | ) 41 | 42 | 43 | def send_email_with_attachment_internal( 44 | to: str, title: str, message: str, attachment_path: str, attachment: str 45 | ) -> str: 46 | """Send an email 47 | 48 | Args: 49 | to (str): The email of the recipient 50 | title (str): The title of the email 51 | message (str): The message content of the email 52 | 53 | Returns: 54 | str: Any error messages 55 | """ 56 | email_sender = getSender() 57 | email_password = getPwd() 58 | 59 | msg = EmailMessage() 60 | msg["Subject"] = title 61 | msg["From"] = email_sender 62 | msg["To"] = to 63 | 64 | signature = os.getenv("EMAIL_SIGNATURE") 65 | if signature: 66 | message += f"\n{signature}" 67 | 68 | msg.set_content(message) 69 | 70 | if attachment_path: 71 | ctype, encoding = mimetypes.guess_type(attachment_path) 72 | if ctype is None or encoding is not None: 73 | # No guess could be made, or the file is encoded (compressed) 74 | ctype = "application/octet-stream" 75 | maintype, subtype = ctype.split("/", 1) 76 | with open(attachment_path, "rb") as fp: 77 | msg.add_attachment( 78 | fp.read(), maintype=maintype, subtype=subtype, filename=attachment 79 | ) 80 | 81 | draft_folder = os.getenv("EMAIL_DRAFT_MODE_WITH_FOLDER") 82 | 83 | if not draft_folder: 84 | smtp_host = os.getenv("EMAIL_SMTP_HOST") 85 | smtp_port = os.getenv("EMAIL_SMTP_PORT") 86 | # send email 87 | with smtplib.SMTP(smtp_host, smtp_port) as smtp: 88 | smtp.ehlo() 89 | smtp.starttls() 90 | smtp.login(email_sender, email_password) 91 | smtp.send_message(msg) 92 | smtp.quit() 93 | return f"Email was sent to {to}!" 94 | else: 95 | conn = imap_open(draft_folder, email_sender, email_password) 96 | conn.append( 97 | draft_folder, 98 | "", 99 | imaplib.Time2Internaldate(time.time()), 100 | str(msg).encode("UTF-8"), 101 | ) 102 | return f"Email went to {draft_folder}!" 103 | 104 | 105 | def read_emails(imap_folder: str = "inbox", imap_search_command: str = "UNSEEN") -> str: 106 | """Read emails from an IMAP mailbox. 107 | 108 | This function reads emails from a specified IMAP folder, using a given IMAP search command. 109 | It returns a list of emails with their details, including the sender, recipient, date, CC, subject, and message body. 110 | 111 | Args: 112 | imap_folder (str, optional): The name of the IMAP folder to read emails from. Defaults to "inbox". 113 | imap_search_command (str, optional): The IMAP search command to filter emails. Defaults to "UNSEEN". 114 | 115 | Returns: 116 | str: A list of dictionaries containing email details if there are any matching emails. Otherwise, returns 117 | a string indicating that no matching emails were found. 118 | """ 119 | email_sender = getSender() 120 | imap_folder = adjust_imap_folder_for_gmail(imap_folder, email_sender) 121 | imap_folder = enclose_with_quotes(imap_folder) 122 | imap_search_ar = split_imap_search_command(imap_search_command) 123 | email_password = getPwd() 124 | 125 | mark_as_seen = os.getenv("EMAIL_MARK_AS_SEEN") 126 | if isinstance(mark_as_seen, str): 127 | mark_as_seen = json.loads(mark_as_seen.lower()) 128 | 129 | conn = imap_open(imap_folder, email_sender, email_password) 130 | 131 | imap_keyword = imap_search_ar[0] 132 | if len(imap_search_ar) == 1: 133 | _, search_data = conn.search(None, imap_keyword) 134 | else: 135 | argument = enclose_with_quotes(imap_search_ar[1]) 136 | _, search_data = conn.search(None, imap_keyword, argument) 137 | 138 | messages = [] 139 | for num in search_data[0].split(): 140 | if mark_as_seen: 141 | message_parts = "(RFC822)" 142 | else: 143 | message_parts = "(BODY.PEEK[])" 144 | _, msg_data = conn.fetch(num, message_parts) 145 | for response_part in msg_data: 146 | if isinstance(response_part, tuple): 147 | msg = email.message_from_bytes(response_part[1]) 148 | 149 | subject, encoding = decode_header(msg["Subject"])[0] 150 | if isinstance(subject, bytes): 151 | subject = subject.decode(encoding) 152 | 153 | body = get_email_body(msg) 154 | from_address = msg["From"] 155 | to_address = msg["To"] 156 | date = msg["Date"] 157 | cc = msg["CC"] if msg["CC"] else "" 158 | 159 | messages.append( 160 | { 161 | "From": from_address, 162 | "To": to_address, 163 | "Date": date, 164 | "CC": cc, 165 | "Subject": subject, 166 | "Message Body": body, 167 | } 168 | ) 169 | 170 | conn.logout() 171 | if not messages: 172 | return ( 173 | f"There are no Emails in your folder `{imap_folder}` " 174 | f"when searching with imap command `{imap_search_command}`" 175 | ) 176 | return messages 177 | 178 | 179 | def adjust_imap_folder_for_gmail(imap_folder: str, email_sender: str) -> str: 180 | if "@gmail" in email_sender.lower() or "@googlemail" in email_sender.lower(): 181 | if "sent" in imap_folder.lower(): 182 | return '"[Gmail]/Sent Mail"' 183 | if "draft" in imap_folder.lower(): 184 | return "[Gmail]/Drafts" 185 | return imap_folder 186 | 187 | 188 | def imap_open( 189 | imap_folder: str, email_sender: str, email_password: str 190 | ) -> imaplib.IMAP4_SSL: 191 | imap_server = os.getenv("EMAIL_IMAP_SERVER") 192 | conn = imaplib.IMAP4_SSL(imap_server) 193 | conn.login(email_sender, email_password) 194 | conn.select(imap_folder) 195 | return conn 196 | 197 | 198 | def get_email_body(msg: email.message.Message) -> str: 199 | if msg.is_multipart(): 200 | for part in msg.walk(): 201 | content_type = part.get_content_type() 202 | content_disposition = str(part.get("Content-Disposition")) 203 | if content_type == "text/plain" and "attachment" not in content_disposition: 204 | return part.get_payload(decode=True).decode() 205 | else: 206 | return msg.get_payload(decode=True).decode() 207 | 208 | 209 | def enclose_with_quotes(s): 210 | # Check if string contains whitespace 211 | has_whitespace = bool(re.search(r"\s", s)) 212 | 213 | # Check if string is already enclosed by quotes 214 | is_enclosed = s.startswith(("'", '"')) and s.endswith(("'", '"')) 215 | 216 | # If string has whitespace and is not enclosed by quotes, enclose it with double quotes 217 | if has_whitespace and not is_enclosed: 218 | return f'"{s}"' 219 | else: 220 | return s 221 | 222 | 223 | def split_imap_search_command(input_string): 224 | input_string = input_string.strip() 225 | parts = input_string.split(maxsplit=1) 226 | parts = [part.strip() for part in parts] 227 | 228 | return parts 229 | -------------------------------------------------------------------------------- /src/autogpt_plugins/email/email_plugin/test_email_plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import patch 3 | from email.message import EmailMessage 4 | from email_plugin import ( 5 | send_email, 6 | read_emails, 7 | imap_open, 8 | send_email_with_attachment_internal, 9 | bothEmailAndPwdSet, 10 | adjust_imap_folder_for_gmail, 11 | enclose_with_quotes, 12 | split_imap_search_command, 13 | ) 14 | from unittest.mock import mock_open 15 | import unittest 16 | from functools import partial 17 | 18 | MOCK_FROM = "sender@example.com" 19 | MOCK_PWD = "secret" 20 | MOCK_TO = "test@example.com" 21 | MOCK_DATE = "Fri, 21 Apr 2023 10:00:00 -0000" 22 | MOCK_CONTENT = "Test message\n" 23 | MOCK_SUBJECT = "Test Subject" 24 | MOCK_IMAP_SERVER = "imap.example.com" 25 | MOCK_SMTP_SERVER = "smtp.example.com" 26 | MOCK_SMTP_PORT = "587" 27 | 28 | MOCK_DRAFT_FOLDER = "Example/Drafts" 29 | MOCK_ATTACHMENT_PATH = "example/file.txt" 30 | MOCK_ATTACHMENT_NAME = "file.txt" 31 | 32 | 33 | class TestEmailPlugin(unittest.TestCase): 34 | @patch.dict( 35 | os.environ, 36 | { 37 | "EMAIL_ADDRESS": "test@example.com", 38 | "EMAIL_PASSWORD": "test_password", 39 | }, 40 | ) 41 | def test_both_email_and_pwd_set(self): 42 | self.assertTrue(bothEmailAndPwdSet()) 43 | 44 | @patch.dict( 45 | os.environ, 46 | { 47 | "EMAIL_PASSWORD": "test_password", 48 | }, 49 | clear=True, 50 | ) 51 | def test_email_not_set(self): 52 | self.assertFalse(bothEmailAndPwdSet()) 53 | 54 | @patch.dict( 55 | os.environ, 56 | { 57 | "EMAIL_ADDRESS": "", 58 | "EMAIL_PASSWORD": "test_password", 59 | }, 60 | clear=True, 61 | ) 62 | def test_email_not_set_2(self): 63 | self.assertFalse(bothEmailAndPwdSet()) 64 | 65 | @patch.dict( 66 | os.environ, 67 | { 68 | "EMAIL_ADDRESS": "test@example.com", 69 | }, 70 | clear=True, 71 | ) 72 | def test_pwd_not_set(self): 73 | self.assertFalse(bothEmailAndPwdSet()) 74 | 75 | @patch.dict(os.environ, {}, clear=True) 76 | def test_both_email_and_pwd_not_set(self): 77 | self.assertFalse(bothEmailAndPwdSet()) 78 | 79 | def test_adjust_imap_folder_for_gmail_normal_cases(self): 80 | self.assertEqual( 81 | adjust_imap_folder_for_gmail("Sent", "user@gmail.com"), 82 | '"[Gmail]/Sent Mail"', 83 | ) 84 | self.assertEqual( 85 | adjust_imap_folder_for_gmail("Drafts", "user@googlemail.com"), 86 | "[Gmail]/Drafts", 87 | ) 88 | self.assertEqual( 89 | adjust_imap_folder_for_gmail("Inbox", "user@gmail.com"), "Inbox" 90 | ) 91 | 92 | def test_adjust_imap_folder_for_gmail_case_insensitivity(self): 93 | self.assertEqual( 94 | adjust_imap_folder_for_gmail("SeNT", "user@GMail.com"), 95 | '"[Gmail]/Sent Mail"', 96 | ) 97 | self.assertEqual( 98 | adjust_imap_folder_for_gmail("DRAFTS", "user@gOogLemail.com"), 99 | "[Gmail]/Drafts", 100 | ) 101 | self.assertEqual( 102 | adjust_imap_folder_for_gmail("InbOx", "user@gmail.com"), "InbOx" 103 | ) 104 | 105 | def test_adjust_imap_folder_for_gmail_non_gmail_sender(self): 106 | self.assertEqual(adjust_imap_folder_for_gmail("Sent", "user@yahoo.com"), "Sent") 107 | self.assertEqual( 108 | adjust_imap_folder_for_gmail("Drafts", "user@hotmail.com"), "Drafts" 109 | ) 110 | self.assertEqual( 111 | adjust_imap_folder_for_gmail("SENT", "gmail@hotmail.com"), "SENT" 112 | ) 113 | 114 | def test_adjust_imap_folder_for_gmail_edge_cases(self): 115 | self.assertEqual(adjust_imap_folder_for_gmail("", "user@gmail.com"), "") 116 | self.assertEqual(adjust_imap_folder_for_gmail("Inbox", ""), "Inbox") 117 | self.assertEqual(adjust_imap_folder_for_gmail("", ""), "") 118 | 119 | def test_enclose_with_quotes(self): 120 | assert enclose_with_quotes("REVERSE DATE") == '"REVERSE DATE"' 121 | assert enclose_with_quotes('"My Search"') == '"My Search"' 122 | assert enclose_with_quotes("'test me'") == "'test me'" 123 | assert enclose_with_quotes("ALL") == "ALL" 124 | assert enclose_with_quotes("quotes needed") == '"quotes needed"' 125 | assert enclose_with_quotes(" whitespace ") == '" whitespace "' 126 | assert enclose_with_quotes("whitespace\te") == '"whitespace\te"' 127 | assert enclose_with_quotes("\"mixed quotes'") == "\"mixed quotes'" 128 | assert enclose_with_quotes("'mixed quotes\"") == "'mixed quotes\"" 129 | 130 | def test_split_imap_search_command(self): 131 | self.assertEqual(split_imap_search_command("SEARCH"), ["SEARCH"]) 132 | self.assertEqual( 133 | split_imap_search_command("SEARCH UNSEEN"), ["SEARCH", "UNSEEN"] 134 | ) 135 | self.assertEqual( 136 | split_imap_search_command(" SEARCH UNSEEN "), ["SEARCH", "UNSEEN"] 137 | ) 138 | self.assertEqual( 139 | split_imap_search_command( 140 | "FROM speixoto@caicm.ca SINCE 01-JAN-2022 BEFORE 01-FEB-2023 HAS attachment xls OR HAS attachment xlsx" 141 | ), 142 | [ 143 | "FROM", 144 | "speixoto@caicm.ca SINCE 01-JAN-2022 BEFORE 01-FEB-2023 HAS attachment xls OR HAS attachment xlsx", 145 | ], 146 | ) 147 | self.assertEqual( 148 | split_imap_search_command("BODY here is my long body"), 149 | ["BODY", "here is my long body"], 150 | ) 151 | self.assertEqual(split_imap_search_command(""), []) 152 | 153 | @patch("imaplib.IMAP4_SSL") 154 | @patch.dict( 155 | os.environ, 156 | { 157 | "EMAIL_ADDRESS": MOCK_FROM, 158 | "EMAIL_PASSWORD": MOCK_PWD, 159 | "EMAIL_IMAP_SERVER": MOCK_IMAP_SERVER, 160 | }, 161 | ) 162 | def test_imap_open(self, mock_imap): 163 | # Test imapOpen function 164 | imap_folder = "inbox" 165 | imap_open(imap_folder, MOCK_FROM, MOCK_PWD) 166 | 167 | # Check if the IMAP object was created and used correctly 168 | mock_imap.assert_called_once_with(MOCK_IMAP_SERVER) 169 | mock_imap.return_value.login.assert_called_once_with(MOCK_FROM, MOCK_PWD) 170 | mock_imap.return_value.select.assert_called_once_with(imap_folder) 171 | 172 | # Test for successful email sending without attachment 173 | @patch("smtplib.SMTP", autospec=True) 174 | @patch.dict( 175 | os.environ, 176 | { 177 | "EMAIL_ADDRESS": MOCK_FROM, 178 | "EMAIL_PASSWORD": MOCK_PWD, 179 | "EMAIL_SMTP_HOST": MOCK_SMTP_SERVER, 180 | "EMAIL_SMTP_PORT": MOCK_SMTP_PORT, 181 | }, 182 | ) 183 | def test_send_email_no_attachment(self, mock_smtp): 184 | result = send_email(MOCK_TO, MOCK_SUBJECT, MOCK_CONTENT) 185 | assert result == f"Email was sent to {MOCK_TO}!" 186 | 187 | mock_smtp.assert_called_once_with(MOCK_SMTP_SERVER, MOCK_SMTP_PORT) 188 | 189 | # Check if the SMTP object was created and used correctly 190 | context = mock_smtp.return_value.__enter__.return_value 191 | context.ehlo.assert_called() 192 | context.starttls.assert_called_once() 193 | context.login.assert_called_once_with(MOCK_FROM, MOCK_PWD) 194 | context.send_message.assert_called_once() 195 | context.quit.assert_called_once() 196 | 197 | # Test for reading emails in a specific folder with a specific search command 198 | @patch("imaplib.IMAP4_SSL") 199 | @patch.dict( 200 | os.environ, 201 | { 202 | "EMAIL_ADDRESS": MOCK_FROM, 203 | "EMAIL_PASSWORD": MOCK_PWD, 204 | "EMAIL_IMAP_SERVER": MOCK_IMAP_SERVER, 205 | }, 206 | ) 207 | def test_read_emails(self, mock_imap): 208 | assert os.getenv("EMAIL_ADDRESS") == MOCK_FROM 209 | 210 | # Create a mock email message 211 | message = EmailMessage() 212 | message["From"] = MOCK_FROM 213 | message["To"] = MOCK_TO 214 | message["Date"] = MOCK_DATE 215 | message["Subject"] = MOCK_SUBJECT 216 | message.set_content(MOCK_CONTENT) 217 | 218 | # Set up mock IMAP server behavior 219 | mock_imap.return_value.search.return_value = (None, [b"1"]) 220 | mock_imap.return_value.fetch.return_value = (None, [(b"1", message.as_bytes())]) 221 | 222 | # Test read_emails function 223 | result = read_emails("inbox", "UNSEEN") 224 | expected_result = [ 225 | { 226 | "From": MOCK_FROM, 227 | "To": MOCK_TO, 228 | "Date": MOCK_DATE, 229 | "CC": "", 230 | "Subject": MOCK_SUBJECT, 231 | "Message Body": MOCK_CONTENT, 232 | } 233 | ] 234 | assert result == expected_result 235 | 236 | # Check if the IMAP object was created and used correctly 237 | mock_imap.return_value.login.assert_called_once_with(MOCK_FROM, MOCK_PWD) 238 | mock_imap.return_value.select.assert_called_once_with("inbox") 239 | mock_imap.return_value.search.assert_called_once_with(None, "UNSEEN") 240 | mock_imap.return_value.fetch.assert_called_once_with(b"1", "(BODY.PEEK[])") 241 | 242 | # Test for reading empty emails 243 | @patch("imaplib.IMAP4_SSL") 244 | @patch.dict( 245 | os.environ, 246 | { 247 | "EMAIL_ADDRESS": MOCK_FROM, 248 | "EMAIL_PASSWORD": MOCK_PWD, 249 | "EMAIL_IMAP_SERVER": MOCK_IMAP_SERVER, 250 | }, 251 | ) 252 | def test_read_empty_emails(self, mock_imap): 253 | assert os.getenv("EMAIL_ADDRESS") == MOCK_FROM 254 | 255 | # Set up mock IMAP server behavior 256 | mock_imap.return_value.search.return_value = (None, [b"0"]) 257 | mock_imap.return_value.fetch.return_value = (None, []) 258 | 259 | # Test read_emails function 260 | result = read_emails("inbox", "UNSEEN") 261 | expected = "There are no Emails in your folder `inbox` " 262 | expected += "when searching with imap command `UNSEEN`" 263 | assert result == expected 264 | 265 | # Check if the IMAP object was created and used correctly 266 | mock_imap.return_value.login.assert_called_once_with(MOCK_FROM, MOCK_PWD) 267 | mock_imap.return_value.select.assert_called_once_with("inbox") 268 | mock_imap.return_value.search.assert_called_once_with(None, "UNSEEN") 269 | mock_imap.return_value.fetch.assert_called_once_with(b"0", "(BODY.PEEK[])") 270 | 271 | # Test for reading emails in a specific folder 272 | # with a specific search command with EMAIL_MARK_AS_SEEN=True 273 | @patch("imaplib.IMAP4_SSL") 274 | @patch.dict( 275 | os.environ, 276 | { 277 | "EMAIL_ADDRESS": MOCK_FROM, 278 | "EMAIL_PASSWORD": MOCK_PWD, 279 | "EMAIL_IMAP_SERVER": MOCK_IMAP_SERVER, 280 | "EMAIL_MARK_AS_SEEN": "True", 281 | }, 282 | ) 283 | def test_read_emails_mark_as_read_true(self, mock_imap): 284 | assert os.getenv("EMAIL_ADDRESS") == MOCK_FROM 285 | 286 | # Create a mock email message 287 | message = EmailMessage() 288 | message["From"] = MOCK_FROM 289 | message["To"] = MOCK_TO 290 | message["Date"] = MOCK_DATE 291 | message["Subject"] = MOCK_SUBJECT 292 | message.set_content(MOCK_CONTENT) 293 | 294 | # Set up mock IMAP server behavior 295 | mock_imap.return_value.search.return_value = (None, [b"1"]) 296 | mock_imap.return_value.fetch.return_value = (None, [(b"1", message.as_bytes())]) 297 | 298 | # Test read_emails function 299 | result = read_emails("inbox", "UNSEEN") 300 | expected_result = [ 301 | { 302 | "From": MOCK_FROM, 303 | "To": MOCK_TO, 304 | "Date": MOCK_DATE, 305 | "CC": "", 306 | "Subject": MOCK_SUBJECT, 307 | "Message Body": MOCK_CONTENT, 308 | } 309 | ] 310 | assert result == expected_result 311 | 312 | # Check if the IMAP object was created and used correctly 313 | mock_imap.return_value.login.assert_called_once_with(MOCK_FROM, MOCK_PWD) 314 | mock_imap.return_value.select.assert_called_once_with("inbox") 315 | mock_imap.return_value.search.assert_called_once_with(None, "UNSEEN") 316 | mock_imap.return_value.fetch.assert_called_once_with(b"1", "(RFC822)") 317 | 318 | # Test for reading emails in a specific folder 319 | # with a specific search command with EMAIL_MARK_AS_SEEN=False 320 | @patch("imaplib.IMAP4_SSL") 321 | @patch.dict( 322 | os.environ, 323 | { 324 | "EMAIL_ADDRESS": MOCK_FROM, 325 | "EMAIL_PASSWORD": MOCK_PWD, 326 | "EMAIL_IMAP_SERVER": MOCK_IMAP_SERVER, 327 | "EMAIL_MARK_AS_SEEN": "False", 328 | }, 329 | ) 330 | def test_read_emails_mark_as_seen_false(self, mock_imap): 331 | assert os.getenv("EMAIL_ADDRESS") == MOCK_FROM 332 | 333 | # Create a mock email message 334 | message = EmailMessage() 335 | message["From"] = MOCK_FROM 336 | message["To"] = MOCK_TO 337 | message["Date"] = MOCK_DATE 338 | message["Subject"] = MOCK_SUBJECT 339 | message.set_content(MOCK_CONTENT) 340 | 341 | # Set up mock IMAP server behavior 342 | mock_imap.return_value.search.return_value = (None, [b"1"]) 343 | mock_imap.return_value.fetch.return_value = (None, [(b"1", message.as_bytes())]) 344 | 345 | # Test read_emails function 346 | result = read_emails("inbox", "UNSEEN") 347 | expected_result = [ 348 | { 349 | "From": MOCK_FROM, 350 | "To": MOCK_TO, 351 | "Date": MOCK_DATE, 352 | "CC": "", 353 | "Subject": MOCK_SUBJECT, 354 | "Message Body": MOCK_CONTENT, 355 | } 356 | ] 357 | assert result == expected_result 358 | 359 | # Check if the IMAP object was created and used correctly 360 | mock_imap.return_value.login.assert_called_once_with(MOCK_FROM, MOCK_PWD) 361 | mock_imap.return_value.select.assert_called_once_with("inbox") 362 | mock_imap.return_value.search.assert_called_once_with(None, "UNSEEN") 363 | mock_imap.return_value.fetch.assert_called_once_with(b"1", "(BODY.PEEK[])") 364 | 365 | def side_effect_for_open(original_open, file_path, *args, **kwargs): 366 | if file_path == MOCK_ATTACHMENT_PATH: 367 | return mock_open(read_data=b"file_content").return_value 368 | return original_open(file_path, *args, **kwargs) 369 | 370 | original_open = open 371 | side_effect_with_original_open = partial(side_effect_for_open, original_open) 372 | 373 | # Test for sending emails with EMAIL_DRAFT_MODE_WITH_FOLDER 374 | @patch("imaplib.IMAP4_SSL") 375 | @patch.dict( 376 | os.environ, 377 | { 378 | "EMAIL_ADDRESS": MOCK_FROM, 379 | "EMAIL_PASSWORD": MOCK_PWD, 380 | "EMAIL_IMAP_SERVER": MOCK_IMAP_SERVER, 381 | "EMAIL_DRAFT_MODE_WITH_FOLDER": MOCK_DRAFT_FOLDER, 382 | }, 383 | ) 384 | @patch(f"{__name__}.imap_open") 385 | @patch("builtins.open", side_effect=side_effect_with_original_open) 386 | def test_send_emails_with_draft_mode(self, mock_file, mock_imap_open, mock_imap): 387 | mock_imap_conn = mock_imap_open.return_value 388 | mock_imap_conn.select.return_value = ("OK", [b"0"]) 389 | mock_imap_conn.append.return_value = ("OK", [b"1"]) 390 | 391 | result = send_email_with_attachment_internal( 392 | MOCK_TO, 393 | MOCK_SUBJECT, 394 | MOCK_CONTENT, 395 | MOCK_ATTACHMENT_PATH, 396 | MOCK_ATTACHMENT_NAME, 397 | ) 398 | assert result == f"Email went to {MOCK_DRAFT_FOLDER}!" 399 | mock_imap.return_value.login.assert_called_once_with(MOCK_FROM, MOCK_PWD) 400 | mock_imap.return_value.select.assert_called_once_with(MOCK_DRAFT_FOLDER) 401 | 402 | # Get the actual MIME message appended 403 | mock_imap.return_value.append.assert_called_once() 404 | 405 | append_args, _ = mock_imap.return_value.append.call_args 406 | actual_mime_msg = append_args[3].decode("utf-8") 407 | 408 | # Check for the presence of relevant information in the MIME message 409 | assert MOCK_FROM in actual_mime_msg 410 | assert MOCK_TO in actual_mime_msg 411 | assert MOCK_SUBJECT in actual_mime_msg 412 | assert MOCK_CONTENT in actual_mime_msg 413 | assert MOCK_ATTACHMENT_NAME in actual_mime_msg 414 | 415 | 416 | if __name__ == "__main__": 417 | unittest.main() 418 | --------------------------------------------------------------------------------