diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ceaff57
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,14 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+[*]
+indent_style = tab
+indent_size = 4
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = false
+insert_final_newline = false
+# Use line wrapping instead of formatting
+max_line_length = 1000
\ No newline at end of file
diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml
new file mode 100644
index 0000000..eedb90e
--- /dev/null
+++ b/.github/workflows/python-build.yml
@@ -0,0 +1,121 @@
+# Builds the package, and, if the commit has a tag assigned to it, create a release for it and publish it to PyPI
+name: Build CI/CD
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ # Builds the package and uploads a build artifiact
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python
+ uses: actions/setup-python@v5.1.0
+ with:
+ python-version: '3.12'
+ - name: Install dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install build
+ - name: Build package
+ run: python -m build
+ - name: Upload a Build Artifact
+ uses: actions/upload-artifact@v4.3.3
+ with:
+ name: distribution-archives
+ path: dist/*
+
+ # Builds binaries with pyinstaller
+ pyinstaller:
+ strategy:
+ matrix:
+ os: ['ubuntu', 'windows', 'macos']
+ runs-on: ${{ matrix.os }}-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python
+ uses: actions/setup-python@v5.1.0
+ with:
+ python-version: '3.12'
+ - name: Install dependencies
+ run: pip install .[packaging]
+ - name: Build executable
+ run: pyinstaller --noconfirm pyinstaller.spec
+ - name: Create .dmg
+ if: runner.os == 'macOS'
+ run: |
+ brew update
+ brew install create-dmg
+ mkdir -p dist/dmg
+ mv dist/*.app dist/dmg
+ create-dmg \
+ --volname "E-mail Draft Generator" \
+ --volicon "assets/icon.png" \
+ --window-pos 200 120 \
+ --window-size 600 300 \
+ --icon-size 100 \
+ --icon "E-mail Draft Generator.app" 175 120 \
+ --hide-extension "E-mail Draft Generator.app" \
+ --app-drop-link 425 120 \
+ "dist/E-mail Draft Generator.dmg" \
+ "dist/dmg/"
+ rm -r "dist/E-mail Draft Generator" "dist/dmg"
+ - name: Create .tar.gz
+ if: runner.os == 'Linux'
+ run: |
+ mkdir release-archives
+ tar -czvf release-archives/email-draft-generator-linux-x86_64.tar.gz -C dist .
+ rm -r "dist/E-mail Draft Generator"
+ cp release-archives/*.tar.gz dist
+ - name: Create .zip
+ if: runner.os == 'Windows'
+ run: |
+ Compress-Archive -Path "dist\E-mail Draft Generator" -DestinationPath email-draft-generator-windows-x86_64.zip
+ move email-draft-generator-windows-x86_64.zip dist
+ - name: Create self-extracting executable
+ if: runner.os == 'Windows'
+ run: |
+ & "C:\Program Files\7-Zip\7z.exe" a email-draft-generator-windows-sfx-x86_64.exe -mx5 -sfx "dist\E-mail Draft Generator"
+ move email-draft-generator-windows-sfx-x86_64.exe dist
+ - name: Upload a Build Artifact
+ uses: actions/upload-artifact@v4.3.3
+ with:
+ name: package-${{ matrix.os }}
+ path: dist/*
+
+ # Creates a release if the commit has a tag associated with it
+ release:
+ needs: [build, pyinstaller]
+ if: startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Download build
+ uses: actions/download-artifact@v4.1.7
+ with:
+ path: dist
+ merge-multiple: true
+ - name: Create release
+ uses: softprops/action-gh-release@v2
+ if: startsWith(github.ref, 'refs/tags/')
+ with:
+ files: dist/*
+
+ # Publish to PyPI if the commit has a tag associated with it
+ publish:
+ needs: build
+ if: startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
+ steps:
+ - uses: actions/checkout@v4
+ - name: Download build
+ uses: actions/download-artifact@v4.1.7
+ with:
+ name: distribution-archives
+ path: dist
+ - name: Publish package
+ uses: pypa/gh-action-pypi-publish@81e9d935c883d0b210363ab89cf05f3894778450
diff --git a/.gitignore b/.gitignore
index 7ad7f38..dd7589f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,168 @@
-companies.json
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+#*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
+.pdm.toml
+.pdm-python
+.pdm-build/
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+# OS-specific stuff
+.DS_Store
+
+# Runtime data files
+.credentials
\ No newline at end of file
diff --git a/.style.yapf b/.style.yapf
new file mode 100644
index 0000000..2ad0e1f
--- /dev/null
+++ b/.style.yapf
@@ -0,0 +1,4 @@
+[style]
+USE_TABS = true
+COLUMN_LIMIT = 10000
+INDENT_BLANK_LINES = true
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..3b61434
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "editor.formatOnSave": true
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index ac1372e..da04677 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,70 @@
# Email Generator
-## Installation
-Go to [zip](https://github.com/roboblazers7617/2024Robot/archive/refs/heads/main.zip). Or go to Code > download zip
-
-## Usage
-### Generate JSON
-- copy and paste the table into `email-template.txt`
-- make sure there is nothing else in the file
-- save it
-- right click on the folder > services > new terminal at folder
-- run `python txt-to-json.py`
-### Generate Emails
-- update the email template and name in `json-to-email.py`
-- run `python json-to-email.py`
-- click on the links
-
-## Errors
-tell me
+## Installation (PyPI)
+```sh
+pip3 install email-draft-generator
+```
+
+## Building from source
+Install the `build` tool with pip and run `python3 -m build` to build the package.
+
+## Usage (command line)
+The utility provides two command-line binaries: `email-generator` and `email-list-parser`.
+
+### email-generator
+`email-generator` takes a JSON-formatted list of E-mail addresses and recipient names, formats them according to the template JSON, and uploads them to Gmail. To set up the Google API, run it once (you will need to provide a valid list of E-mail addresses for it to run), and it will guide you through setting this up. Most of the usage specifics are described in the usage help, which you can show by running `email-generator -h`.
+
+### email-list-parser
+`email-list-parser` takes a CSV formatted list or a newline-seperated list of values and turn it into a JSON file which can be piped into `email-generator`.
+
+#### Text file format
+The values should be formatted as follows
+```
+Company Name 1
+e-mail@company1.com
+Company Name 2
+e-mail@company2.com
+Company Name 3
+e-mail@company3.com
+...
+```
+
+#### CSV format
+The CSV file should have the keys in the order `name,email`
+
+### Template Format
+The E-mail template is a JSON file with the following keys
+| Key | Value |
+|--------------------------|--------------------------------------------------|
+| `subject` (formatted) | E-mail subject |
+| `body` (formatted) | E-mail body |
+| `attachments` (optional) | Paths to any attachments that should be included |
+
+Formatted fields can include the following variables
+| Variable | Data |
+|-----------|----------------|
+| `{name}` | Company name |
+| `{email}` | Company E-mail |
+
+## Installation (scripts)
+Download the latest release as a `tar.gz` file.
+
+### Setup
+#### Build program
+Run `setup.sh` (can be run by simply double-clicking on the file).
+
+#### Set up the template
+Update the email template in `data/template.json` according to the [template format](#template-format).
+
+#### Google API
+Run the program once, and it will guide you through setting up the Google API.
+
+## Usage (scripts)
+### Prepare data
+Copy and paste the table into `data/email-list.txt`.
+### Run the program
+Run `run.sh` (can be run by double-clicking as well)
+The first time it is run, you will need to authenticate with the account that you want to upload the drafts to. A browser window should be opened automatically to do this in (if it is not, copy and paste the URL from the terminal window into your browser). The authentication flow may not work in Firefox or other Gecko-based browsers, so try Chrome, Safari, or another Chromium- or Webkit-based browser if it doesn't work for you.
+
+## Usage (GUI)
+The utility provides two GUI binaries: `email-generator-gui` and `email-template-editor-gui`. These take no command line arguments.
\ No newline at end of file
diff --git a/assets/icon.png b/assets/icon.png
new file mode 100644
index 0000000..6361fe1
Binary files /dev/null and b/assets/icon.png differ
diff --git a/assets/icon.svg b/assets/icon.svg
new file mode 100644
index 0000000..e6c5d80
--- /dev/null
+++ b/assets/icon.svg
@@ -0,0 +1,217 @@
+
+
+
+
diff --git a/assets/icon_macos.png b/assets/icon_macos.png
new file mode 100644
index 0000000..b52bcf8
Binary files /dev/null and b/assets/icon_macos.png differ
diff --git a/assets/icon_macos.svg b/assets/icon_macos.svg
new file mode 100644
index 0000000..8fb804a
--- /dev/null
+++ b/assets/icon_macos.svg
@@ -0,0 +1,244 @@
+
+
+
+
diff --git a/data/email-list.csv b/data/email-list.csv
new file mode 100644
index 0000000..66012fd
--- /dev/null
+++ b/data/email-list.csv
@@ -0,0 +1,6 @@
+google,google@example.com
+apple,apple@example.com
+uhs,uhs@example.com
+amazon,example.com
+facebook,facebook@example.com
+microsoft,sdoijf@example.com
\ No newline at end of file
diff --git a/data/email-list.txt b/data/email-list.txt
new file mode 100644
index 0000000..9be97fc
--- /dev/null
+++ b/data/email-list.txt
@@ -0,0 +1,12 @@
+google
+google@example.com
+apple
+apple@example.com
+uhs
+uhs@example.com
+amazon
+example.com
+facebook
+facebook@example.com
+microsoft
+sdoijf@example.com
\ No newline at end of file
diff --git a/data/template.json b/data/template.json
new file mode 100644
index 0000000..bbb0870
--- /dev/null
+++ b/data/template.json
@@ -0,0 +1,12 @@
+{
+ "subject": "Special offer!!!!",
+ "body": "Dear {name},\nWe are pleased to inform you that your company has been selected for a special offer.\nPlease find attached the details of the offer.\n\nBest regards,\nYour Company",
+ "attachments": [
+ {
+ "path": "data/test_pdf.pdf"
+ },
+ {
+ "path": "data/test_image.png"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/data/test_image.png b/data/test_image.png
new file mode 100644
index 0000000..a356038
Binary files /dev/null and b/data/test_image.png differ
diff --git a/data/test_pdf.pdf b/data/test_pdf.pdf
new file mode 100644
index 0000000..e4a5538
Binary files /dev/null and b/data/test_pdf.pdf differ
diff --git a/email-template.txt b/email-template.txt
deleted file mode 100644
index b1d3d10..0000000
--- a/email-template.txt
+++ /dev/null
@@ -1,12 +0,0 @@
-google
-google@google.com
-apple
-apple@apple.com
-uhs
-uhs@uhs.org
-amazon
-amazon.com
-facebook
-facebook@meta.com
-microsoft
-sdoijf@osdif.com
\ No newline at end of file
diff --git a/index.html b/index.html
deleted file mode 100644
index 58a7942..0000000
--- a/index.html
+++ /dev/null
@@ -1,6 +0,0 @@
- text
- text
- text
- text
- text
- text
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..25dff38
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+parent_path="$HOME/.local/share/email-generator"
+cd "$parent_path"
+python3 -m venv --system-site-packages "$parent_path/.venv"
+source "$parent_path/.venv/bin/activate"
+pip3 install email-draft-generator
\ No newline at end of file
diff --git a/json-to-email.py b/json-to-email.py
deleted file mode 100644
index 71fc6fe..0000000
--- a/json-to-email.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import json
-import urllib.parse
-# Load the companies from the JSON file
-with open('companies.json') as f:
- companies = json.load(f)
-
-# Define your email template
-email_template = """Dear {company_name},
-
-We are pleased to inform you that your company has been selected for a special offer.
-Please find attached the details of the offer.
-
-Best regards,
-Your Company"""
-email_name=urllib.parse.quote("Give us money please!!!!")
-
-# Generate emails for each company
-for company in companies:
- email = urllib.parse.quote(email_template.format(company_name=company['name']))
- email_link = "mailto:" + f"{company['email']}?subject={email_name}&body={email}"
- # print(f" text")
- print(email_link)
-
-
diff --git a/launch.sh b/launch.sh
new file mode 100755
index 0000000..9a68ce0
--- /dev/null
+++ b/launch.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
+cd "$parent_path"
+source "$parent_path/.venv/bin/activate"
+email-list-parser "$parent_path/data/email-list.txt" | email-generator-gui
\ No newline at end of file
diff --git a/pyinstaller.spec b/pyinstaller.spec
new file mode 100644
index 0000000..8ffa6cf
--- /dev/null
+++ b/pyinstaller.spec
@@ -0,0 +1,61 @@
+# -*- mode: python ; coding: utf-8 -*-
+
+import importlib.metadata
+
+added_files = [
+ ('assets/icon.png', 'email_draft_generator/gui/assets'),
+ ('assets/icon_macos.png', 'email_draft_generator/gui/assets'),
+]
+
+a = Analysis(
+ ['src/email_draft_generator/gui/__main__.py'],
+ pathex=[],
+ binaries=[],
+ datas=added_files,
+ hiddenimports=[],
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ excludes=[],
+ noarchive=False,
+ optimize=0,
+)
+
+pyz = PYZ(a.pure)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ [],
+ exclude_binaries=True,
+ name='E-mail Draft Generator',
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ upx=True,
+ console=False,
+ disable_windowed_traceback=False,
+ argv_emulation=False,
+ target_arch=None,
+ codesign_identity=None,
+ entitlements_file=None,
+ icon=['assets/icon.png'],
+)
+
+coll = COLLECT(
+ exe,
+ a.binaries,
+ a.datas,
+ strip=False,
+ upx=True,
+ upx_exclude=[],
+ name='E-mail Draft Generator',
+)
+
+app = BUNDLE(
+ coll,
+ name='E-mail Draft Generator.app',
+ icon='assets/icon_macos.png',
+ bundle_identifier=None,
+ version=importlib.metadata.version('email-draft-generator'),
+)
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..6148c2d
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,55 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "email_draft_generator"
+version = "0.1.2"
+description = "A utility to generate E-mail drafts from a list of E-mail addresses."
+readme = "README.md"
+license = { file = "LICENSE" }
+requires-python = ">=3.10"
+keywords = ["email", "gmail", "email-template", "email-generator"]
+
+# This should be your name or the name of the organization who originally authored the project, and a valid email address corresponding to the name listed.
+authors = [
+ { name = "Brandon Clague", email = "94200657+fodfodfod@users.noreply.github.com" },
+ { name = "Max Nargang", email = "CoffeeCoder1@outlook.com" },
+]
+
+# This should be your name or the names of the organization who currently maintains the project, and a valid email address corresponding to the name listed.
+maintainers = [{ name = "Max Nargang", email = "CoffeeCoder1@outlook.com" }]
+
+# For a list of valid classifiers, see https://pypi.org/classifiers/
+classifiers = [
+ # How mature is this project? Common values are
+ # 3 - Alpha
+ # 4 - Beta
+ # 5 - Production/Stable
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Customer Service",
+ "Topic :: Communications :: Email :: Mailing List Servers",
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python :: 3",
+]
+
+dependencies = [
+ "google-api-python-client",
+ "google-auth-httplib2",
+ "google-auth-oauthlib",
+ "json-fix",
+]
+
+[project.optional-dependencies]
+packaging = ["pyinstaller", "pillow"]
+
+[project.urls]
+"Homepage" = "https://github.com/CoffeeCoder1/Email-Generator"
+"Bug Reports" = "https://github.com/CoffeeCoder1/Email-Generator/issues"
+"Source" = "https://github.com/CoffeeCoder1/Email-Generator"
+
+[project.scripts]
+email-generator = "email_draft_generator.main:main"
+email-list-parser = "email_draft_generator.file_parser.main:main"
+email-generator-gui = "email_draft_generator.gui.main:main"
+email-template-editor-gui = "email_draft_generator.gui.template_editor:main"
diff --git a/run.sh b/run.sh
new file mode 100755
index 0000000..b62851d
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
+cd "$parent_path"
+source "$parent_path/.venv/bin/activate"
+email-list-parser "$parent_path/data/email-list.txt" > "/tmp/email-list.json"
+email-generator -t "$parent_path/data/template.json" "/tmp/email-list.json"
\ No newline at end of file
diff --git a/run_gui.py b/run_gui.py
new file mode 100755
index 0000000..832ee94
--- /dev/null
+++ b/run_gui.py
@@ -0,0 +1,9 @@
+#!/usr/bin/env python3
+
+import sys
+
+sys.path.append('./src')
+
+import email_draft_generator.gui.main as main
+
+main.main()
diff --git a/setup.sh b/setup.sh
new file mode 100755
index 0000000..0f2d364
--- /dev/null
+++ b/setup.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
+cd "$parent_path"
+python3 -m venv --system-site-packages "$parent_path/.venv"
+source "$parent_path/.venv/bin/activate"
+pip3 install build
+python3 -m build
+pip3 install --force-reinstall ./dist/*.whl
\ No newline at end of file
diff --git a/src/email_draft_generator/__init__.py b/src/email_draft_generator/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/email_draft_generator/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/email_draft_generator/__main__.py b/src/email_draft_generator/__main__.py
new file mode 100644
index 0000000..7d9b56e
--- /dev/null
+++ b/src/email_draft_generator/__main__.py
@@ -0,0 +1,4 @@
+from email_draft_generator import main
+
+if __name__ == "__main__":
+ main.main()
diff --git a/src/email_draft_generator/attachment.py b/src/email_draft_generator/attachment.py
new file mode 100644
index 0000000..a301e98
--- /dev/null
+++ b/src/email_draft_generator/attachment.py
@@ -0,0 +1,38 @@
+import os
+import mimetypes
+
+
+class EmailAttachment:
+
+ def __init__(self, *, data, path: os.PathLike, filename: str | None = None):
+ self.data = data
+ self.path = path
+ # If the filename is None, default to the name of the source file
+ if filename == None:
+ self.filename = os.path.basename(path)
+ else:
+ self.filename = filename
+
+ type_subtype, _ = mimetypes.guess_type(path)
+ self.maintype, self.subtype = type_subtype.split("/")
+
+ def __eq__(self, other):
+ """EmailAttachment objects are considered equal if they have the same contents."""
+ if type(self) != type(other):
+ return NotImplemented
+ return vars(self) == vars(other)
+
+ def __json__(self):
+ """Returns a dictionary for JSON serialization."""
+ return {"path": str(self.path), "filename": self.filename}
+
+ @classmethod
+ def from_path(cls, path: os.PathLike, filename: str | None = None):
+ with open(path, "rb") as fp:
+ data = fp.read()
+
+ return cls(data=data, path=path, filename=filename)
+
+ @classmethod
+ def from_dict(cls, dictionary: dict):
+ return cls.from_path(path=dictionary.get('path'), filename=dictionary.get('filename'))
diff --git a/src/email_draft_generator/email_drafter.py b/src/email_draft_generator/email_drafter.py
new file mode 100644
index 0000000..c45b4f8
--- /dev/null
+++ b/src/email_draft_generator/email_drafter.py
@@ -0,0 +1,28 @@
+from email_draft_generator import gmail
+from email_draft_generator.email_list import EmailRecipient
+from email_draft_generator.email_template import EmailTemplate
+
+import concurrent.futures
+from tkinter import ttk
+
+
+class EmailDrafter:
+ """Utility class to draft E-mails"""
+
+ def __init__(self, error_button: ttk.Button | None = None):
+ self.errors = []
+ self.error_button = error_button
+
+ def generate_drafts(self, recipients, template: EmailTemplate, creds, progressbar: ttk.Progressbar | None = None):
+ self.errors = []
+ with concurrent.futures.ProcessPoolExecutor() as executor:
+ for recipient in recipients:
+ draft = gmail.create_draft(creds, template.create_email_body(recipient))
+ if draft[1] != None:
+ self.errors.append(recipient)
+ if self.error_button != None:
+ self.error_button.config(state='normal')
+ if progressbar != None:
+ progressbar.step()
+ if progressbar != None:
+ progressbar.destroy()
diff --git a/src/email_draft_generator/email_list.py b/src/email_draft_generator/email_list.py
new file mode 100644
index 0000000..4ab38dc
--- /dev/null
+++ b/src/email_draft_generator/email_list.py
@@ -0,0 +1,24 @@
+import re
+
+
+class EmailRecipient:
+ """A recipient on the E-mail list."""
+
+ def __init__(self, *, name="", email=""):
+ self.name = name
+ self.email = email
+
+ def __json__(self):
+ """Returns a dictionary for JSON serialization."""
+ return self.__dict__
+
+ @classmethod
+ def from_dict(cls, dictionary: dict):
+ """Creates an EmailRecipient from a dictionary with keys 'name' and 'email'."""
+ return cls(name=dictionary.get('name'), email=dictionary.get('email'))
+
+ @property
+ def valid(self):
+ """Checks if the E-mail address is valid."""
+ pattern = r"^[\w\-\.]+@([\w-]+\.)+[\w-]{2,}$"
+ return re.match(pattern, self.email) != None
diff --git a/src/email_draft_generator/email_template.py b/src/email_draft_generator/email_template.py
new file mode 100644
index 0000000..060ce89
--- /dev/null
+++ b/src/email_draft_generator/email_template.py
@@ -0,0 +1,85 @@
+import json
+import base64
+from email.message import EmailMessage
+
+from email_draft_generator.attachment import EmailAttachment
+from email_draft_generator.email_list import EmailRecipient
+
+
+class EmailTemplate:
+ """An E-mail template."""
+
+ def __init__(
+ self,
+ *,
+ subject: str | None = None,
+ body: str | None = None,
+ attachments: [] = [],
+ ):
+ """Creates a new E-mail template."""
+ self.subject = subject
+ self.body = body
+ self.attachments = attachments
+
+ def __eq__(self, other):
+ """EmailTemplate objects are considered equal if they have the same contents."""
+ if type(self) != type(other):
+ return NotImplemented
+ return vars(self) == vars(other)
+
+ def __json__(self):
+ """Returns a dictionary for JSON serialization."""
+ return self.__dict__
+
+ def create_email_body(self, recipient: EmailRecipient):
+ """Uses the template to generate an E-mail body with the provided company."""
+ mime_message = EmailMessage()
+
+ # Add headers
+ mime_message["To"] = recipient.email
+ # Doesn't seem to be required for Gmail
+ # mime_message["From"] = ""
+ if self.subject != None:
+ mime_message["Subject"] = str(self.subject).format_map(recipient.__dict__)
+
+ # Add text
+ mime_message.set_content(str(self.body).format_map(recipient.__dict__))
+
+ # Add attachments
+ for attachment in self.attachments:
+ mime_message.add_attachment(attachment.data, maintype=attachment.maintype, subtype=attachment.subtype, filename=attachment.filename)
+
+ encoded_message = base64.urlsafe_b64encode(mime_message.as_bytes()).decode()
+
+ return {"message": {"raw": encoded_message}}
+
+ @classmethod
+ def get_sample_template(cls):
+ """Returns a sample E-mail template."""
+ return cls(
+ subject="Test E-mail",
+ body="""This is a template E-mail used to test an E-mail generation program. Please disregard.
+
+company.name: {name}
+company.email: {email}""",
+ )
+
+ @classmethod
+ def from_dict(cls, dictionary: dict):
+ """Parses an E-mail template from a dictionary with keys 'subject', 'body', and 'attachments'."""
+ template = cls(
+ subject=dictionary.get('subject'),
+ body=dictionary.get('body'),
+ )
+ template.attachments = [] # Make sure that there are no attachments left over from the previous run
+ if 'attachments' in dictionary:
+ for attachment in dictionary['attachments']:
+ template.attachments.append(EmailAttachment.from_dict(attachment))
+ return template
+
+ @classmethod
+ def from_file(cls, file):
+ """Parses an E-mail template out of a JSON file."""
+ template_dict = json.load(file)
+ template = cls.from_dict(template_dict)
+ return template
diff --git a/src/email_draft_generator/file_parser/__init__.py b/src/email_draft_generator/file_parser/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/email_draft_generator/file_parser/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/email_draft_generator/file_parser/__main__.py b/src/email_draft_generator/file_parser/__main__.py
new file mode 100644
index 0000000..d0e8357
--- /dev/null
+++ b/src/email_draft_generator/file_parser/__main__.py
@@ -0,0 +1,4 @@
+import email_draft_generator.file_parser.main as main
+
+if __name__ == "__main__":
+ main.main()
diff --git a/src/email_draft_generator/file_parser/csv_parser.py b/src/email_draft_generator/file_parser/csv_parser.py
new file mode 100644
index 0000000..f422e8a
--- /dev/null
+++ b/src/email_draft_generator/file_parser/csv_parser.py
@@ -0,0 +1,14 @@
+import csv
+import concurrent.futures
+
+from email_draft_generator.email_list import EmailRecipient
+
+
+def parse(data):
+ """Takes a CSV file with fields `name,email` and parses it into a list of recipients."""
+ recipients = []
+ reader = csv.DictReader(data, fieldnames=["name", "email"])
+ with concurrent.futures.ProcessPoolExecutor() as executor:
+ for row in reader:
+ recipients.append(EmailRecipient.from_dict(row))
+ return recipients
diff --git a/src/email_draft_generator/file_parser/main.py b/src/email_draft_generator/file_parser/main.py
new file mode 100644
index 0000000..d1b5d36
--- /dev/null
+++ b/src/email_draft_generator/file_parser/main.py
@@ -0,0 +1,32 @@
+import sys
+import argparse
+import json
+import json_fix
+
+from email_draft_generator.file_parser import text_parser
+from email_draft_generator.file_parser import csv_parser
+
+
+def main():
+ # Command-line arguments
+ parser = argparse.ArgumentParser(prog='email-list-parser', description='Takes a list of E-mail addresses and turns it into a JSON file.')
+
+ parser.add_argument('--format', choices=['text', 'csv'], default='text', help='the format of the input file (default: text)')
+ parser.add_argument('infile', type=argparse.FileType('r'), help='the list of e-mail addresses to parse')
+
+ args = parser.parse_args()
+
+ try:
+ if args.format == 'text':
+ recipients = text_parser.parse(args.infile.readlines())
+ elif args.format == 'csv':
+ recipients = csv_parser.parse(args.infile)
+ else:
+ recipients = None
+
+ if recipients != None:
+ json.dump(recipients, sys.stdout, indent="\t")
+ else:
+ raise ValueError("Unsupported filetype!")
+ except:
+ raise ValueError("Invalid input file")
diff --git a/src/email_draft_generator/file_parser/text_parser.py b/src/email_draft_generator/file_parser/text_parser.py
new file mode 100644
index 0000000..3bc53b6
--- /dev/null
+++ b/src/email_draft_generator/file_parser/text_parser.py
@@ -0,0 +1,23 @@
+import concurrent.futures
+
+from email_draft_generator.email_list import EmailRecipient
+
+
+def parse(data):
+ """Takes a text file and parses it into a list of recipients.
+Data format is a newline-seperated list of company names and e-mail adresses like this:
+```
+Company Name 1
+e-mail@company1.com
+Company Name 2
+e-mail@company2.com
+Company Name 3
+e-mail@company3.com
+...
+```
+"""
+ recipients = []
+ with concurrent.futures.ProcessPoolExecutor() as executor:
+ for i in range(0, len(data), 2):
+ recipients.append(EmailRecipient.from_dict({'name': data[i].strip(), 'email': data[i + 1].strip()}))
+ return recipients
diff --git a/src/email_draft_generator/gmail.py b/src/email_draft_generator/gmail.py
new file mode 100644
index 0000000..01f70f6
--- /dev/null
+++ b/src/email_draft_generator/gmail.py
@@ -0,0 +1,102 @@
+import os
+from pathlib import Path
+
+from google.auth.transport.requests import Request
+from google.oauth2.credentials import Credentials
+from google_auth_oauthlib.flow import InstalledAppFlow
+from googleapiclient.discovery import build
+from googleapiclient.errors import HttpError
+
+# If modifying these scopes, delete the file token.json.
+SCOPES = ["https://www.googleapis.com/auth/gmail.compose"]
+
+
+def get_oauth_flow(global_creds_path, creds_path=None):
+ """Get an OAuth2 flow from the specified credentials, copying them to the global location if they are not already there."""
+ if not os.path.exists(global_creds_path) and creds_path:
+ # If the global credentials do not exist, copy the provided credentials there
+ input_path = Path(creds_path)
+ with open(input_path, "r") as creds_input:
+ oauth_creds = creds_input.read()
+
+ flow = InstalledAppFlow.from_client_secrets_file(creds_path, SCOPES)
+
+ output_path = Path(global_creds_path)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(global_creds_path, "w") as creds_output:
+ creds_output.write(oauth_creds)
+
+ input_path.unlink(missing_ok=True)
+ else:
+ # If they do, create an OAuth flow
+ flow = InstalledAppFlow.from_client_secrets_file(global_creds_path, SCOPES)
+ return flow
+
+
+def get_token(token_path):
+ """Get the user's OAuth2 token and refresh it if it is expired."""
+ # The file token.json stores the user's access and refresh tokens, and is created automatically when the authorization flow completes for the first time.
+ if os.path.exists(token_path):
+ creds = Credentials.from_authorized_user_file(token_path, SCOPES)
+ # Refresh expired creds
+ if creds and creds.expired and creds.refresh_token:
+ creds.refresh(Request())
+ else:
+ creds = None
+ return creds
+
+
+def write_token(creds, token_path):
+ """Sets the user's token globally."""
+ output_path = Path(token_path)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ with open(token_path, "w") as token:
+ token.write(creds.to_json())
+
+
+def get_creds(token_path, creds_path):
+ """Gets the user's Gmail credentials, asking for input on the command line where necessary."""
+ creds = get_token(token_path)
+ # If there are no (valid) credentials available, let the user log in.
+ if not creds or not creds.valid:
+ # Create new creds
+ # If OAuth2 creds do not exist, tell the user to create them
+ if not os.path.exists(creds_path):
+ creds_path_input = input("No OAuth2 Credentials exist!\nFollow the guide at https://developers.google.com/gmail/api/quickstart/python#set_up_your_environment to create them, and, when you are at the step to configure the OAuth consent screen, add the `gmail.compose` scope.\nDownload them to your device, copy the file path, and enter it here: ")
+ flow = get_oauth_flow(creds_path, creds_path_input)
+ else:
+ flow = get_oauth_flow(creds_path)
+ creds = flow.run_local_server(port=0)
+ # Save the credentials for the next run
+ write_token(creds, token_path)
+ return creds
+
+
+def create_draft(creds, body):
+ """Creates an email draft with the provided body"""
+ error_output = None
+ try:
+ # Create Gmail API client
+ service = build("gmail", "v1", credentials=creds)
+
+ # pylint: disable=E1101
+ draft = (service.users().drafts().create(userId="me", body=body).execute())
+ print(f'Draft id: {draft["id"]}\nDraft message: {draft["message"]}')
+ except HttpError as error:
+ print(f"An error occurred: {error}")
+ draft = None
+ error_output = error
+ return [draft, error_output]
+
+
+def get_user_info(creds):
+ """Creates an email draft with the provided body"""
+ try:
+ # Create Gmail API client
+ service = build("gmail", "v1", credentials=creds)
+
+ profile = service.users().getProfile(userId='me').execute()
+ except HttpError as error:
+ print(f"An error occurred: {error}")
+ profile = None
+ return profile
diff --git a/src/email_draft_generator/gui/__init__.py b/src/email_draft_generator/gui/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/email_draft_generator/gui/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/email_draft_generator/gui/__main__.py b/src/email_draft_generator/gui/__main__.py
new file mode 100644
index 0000000..d920be1
--- /dev/null
+++ b/src/email_draft_generator/gui/__main__.py
@@ -0,0 +1,8 @@
+import multiprocessing
+from email_draft_generator.gui import main
+
+if __name__ == "__main__":
+ # Pyinstaller fix - fixes a problem where multiprocessing will cause multiple instances of the program to spawn
+ multiprocessing.freeze_support()
+
+ main.main()
diff --git a/src/email_draft_generator/gui/gmail_gui.py b/src/email_draft_generator/gui/gmail_gui.py
new file mode 100644
index 0000000..2d046b8
--- /dev/null
+++ b/src/email_draft_generator/gui/gmail_gui.py
@@ -0,0 +1,63 @@
+import os
+
+import tkinter as tk
+from tkinter import ttk
+from tkinter import filedialog
+import webbrowser
+
+from email_draft_generator import gmail
+
+
+class CredsPopup(tk.Toplevel):
+
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.wm_title("OAuth2 Credentials")
+
+ frm = ttk.Frame(self, padding=10)
+ frm.grid()
+
+ ttk.Label(self, text="No OAuth2 Credentials exist!").grid(row=0)
+ ttk.Label(self, text="Follow the guide below to create them, and, when you are at the step to configure the OAuth consent screen, add the `gmail.compose` scope.\nDownload them to your device, and select the file.").grid(row=1, column=0)
+ ttk.Button(self, text="Guide", command=self.open_guide).grid(row=2, column=0)
+ ttk.Button(self, text="Select File", command=self.choose_path).grid(row=2, column=1)
+ ttk.Button(self, text="Cancel", command=self.destroy).grid(row=2, column=2)
+
+ self.selected_path = None
+
+ def open_guide(self):
+ webbrowser.open_new('https://developers.google.com/gmail/api/quickstart/python#set_up_your_environment')
+
+ def choose_path(self):
+ """Prompts the user to select the creds file."""
+ self.selected_path = filedialog.askopenfilename(title="Select credentials file", filetypes=[("Google API credentials files", ".json")])
+ if not (self.selected_path == None or self.selected_path == ''):
+ self.destroy()
+
+ def show(self):
+ """Shows the window and returns the selected file path"""
+ self.deiconify()
+ self.wm_protocol("WM_DELETE_WINDOW", self.destroy)
+ self.wait_window(self)
+ return self.selected_path
+
+
+def get_creds(token_path, creds_path, root=None):
+ """Gets the user's Gmail credentials, asking for input on the command line where necessary if `prompt` is `True`."""
+ creds = gmail.get_token(token_path)
+ # If there are no (valid) credentials available and `prompt` is `True`, let the user log in.
+ if root and (not creds or not creds.valid):
+ # Create new creds
+ # If OAuth2 creds do not exist, tell the user to create them
+ if not os.path.exists(creds_path):
+ popup = CredsPopup(root)
+ creds_path_input = popup.show()
+ flow = gmail.get_oauth_flow(creds_path, creds_path_input)
+ else:
+ flow = gmail.get_oauth_flow(creds_path)
+ creds = flow.run_local_server(port=0)
+ # Save the credentials for the next run
+ gmail.write_token(creds, token_path)
+ # Refocus the main window
+ root.parent.focus_force()
+ return creds
diff --git a/src/email_draft_generator/gui/main.py b/src/email_draft_generator/gui/main.py
new file mode 100644
index 0000000..73dc864
--- /dev/null
+++ b/src/email_draft_generator/gui/main.py
@@ -0,0 +1,187 @@
+import fileinput
+import json
+import mimetypes
+import os
+from os import path
+import concurrent.futures
+from pathlib import Path
+import sys
+import threading
+from PIL import ImageTk
+import platform
+
+import tkinter as tk
+from tkinter import ttk
+from tkinter import filedialog
+from tkinter import messagebox
+
+from email_draft_generator import gmail
+from email_draft_generator.gui import gmail_gui
+from email_draft_generator.file_parser import csv_parser
+from email_draft_generator.file_parser import text_parser
+from email_draft_generator.email_drafter import EmailDrafter
+from email_draft_generator.email_template import EmailTemplate
+from email_draft_generator.email_list import EmailRecipient
+from email_draft_generator.gui.template_editor import TemplateEditorPopup
+
+# TODO: Use a keyring for these
+global_creds_dir = os.path.expanduser("~/.local/share/email-generator/credentials")
+global_token_path = f"{global_creds_dir}/token.json"
+global_creds_path = f"{global_creds_dir}/credentials.json"
+
+
+class App(tk.Frame):
+ # Get creds if they exist, but do not prompt the user if they don't
+ creds = gmail_gui.get_creds(global_token_path, global_creds_path)
+
+ def __init__(self, master):
+ # Set up a window
+ super().__init__(master, padx=10, pady=10)
+ self.pack()
+
+ self.parent = master
+ self.errors = []
+
+ # Frame for the main app content
+ frm = self
+ frm.grid()
+
+ # Create a template editor and hide it
+ self.template_editor = TemplateEditorPopup(self)
+ self.template_editor.withdraw()
+
+ self.authenticate_button = ttk.Button(frm, text="Authenticate", command=self.authenticate_button_callback)
+ self.authenticate_button.grid(column=0, row=0)
+ self.account_label = ttk.Label(frm)
+ self.account_label.grid(column=1, row=0, columnspan=2)
+
+ ttk.Label(frm, text="Template").grid(column=0, row=1)
+ ttk.Button(frm, text="Open", command=self.template_editor.template_editor.load_template).grid(column=1, row=1)
+ ttk.Button(frm, text="Edit", command=self.edit_template).grid(column=2, row=1)
+
+ ttk.Label(frm, text="E-mail list").grid(column=0, row=2)
+ ttk.Button(frm, text="Open", command=self.load_email_list).grid(column=1, row=2)
+
+ ttk.Button(frm, text="Draft E-mails", command=self.send_emails).grid(column=0, row=3)
+ self.error_button = ttk.Button(frm, text="View Errors", command=self.view_errors, state="disabled")
+ self.error_button.grid(column=1, row=3)
+
+ self.update_authenticate_label()
+
+ self.email_drafter = EmailDrafter(self.error_button)
+
+ def authenticate_button_callback(self):
+ """Callback for the authentication button, logs out if already authenticated."""
+ if not self.creds or not self.creds.valid:
+ self.authenticate()
+ else:
+ self.log_out()
+
+ def authenticate(self):
+ self.creds = gmail_gui.get_creds(global_token_path, global_creds_path, root=self)
+ self.update_authenticate_label()
+
+ def log_out(self):
+ if messagebox.askyesno("Log out?", "Would you like to log out?"):
+ Path(global_token_path).unlink(missing_ok=True)
+ self.creds = gmail_gui.get_creds(global_token_path, global_creds_path) # Clear the credentials variable
+ self.update_authenticate_label()
+
+ def update_authenticate_label(self):
+ """Update the authenticate button text to show if you are logged in or not."""
+ if not self.creds or not self.creds.valid:
+ self.authenticate_button.config(text="Authenticate")
+ self.account_label.config(text="")
+ else:
+ self.authenticate_button.config(text="Log Out")
+ profile = gmail.get_user_info(self.creds)
+ if profile != None:
+ self.account_label.config(text=f"Logged in as {profile['emailAddress']}")
+ else:
+ self.account_label.config(text=f"Unable to retrieve E-mail address")
+
+ def edit_template(self):
+ """Opens the template editor"""
+ self.template_editor.show()
+ # TODO: Fix the thing where the parent window is focused on return
+ #self.parent.deiconify()
+
+ def load_email_list(self):
+ """Opens an E-mail list"""
+ email_list_path = filedialog.askopenfilename(title="Select E-mail list", filetypes=[("E-mail list files", ".json .csv .txt")])
+
+ if email_list_path == None or email_list_path == '':
+ return
+
+ # Get the mimetype of the file so we can figure out how to parse it
+ type_subtype, _ = mimetypes.guess_type(email_list_path)
+
+ # Parse list
+ with open(email_list_path) as email_list_file:
+ if type_subtype == "text/json":
+ # Load JSON data
+ email_list_dict = json.load(email_list_file)
+
+ # Convert the list of dictionaries to a list of recipients
+ email_list = []
+ with concurrent.futures.ProcessPoolExecutor() as executor:
+ for recipient in email_list_dict:
+ email_list.append(EmailRecipient.from_dict(recipient))
+
+ elif type_subtype == "text/csv":
+ # Load CSV data
+ self.email_list = csv_parser.parse(email_list_file)
+ elif type_subtype == "text/plain":
+ # Load plaintext data
+ self.email_list = text_parser.parse(email_list_file)
+
+ def send_emails(self):
+ # If not authenticated, prompt the user to authenticate
+ if not self.creds or not self.creds.valid:
+ self.authenticate()
+
+ # If no email list was provided, load the email list
+ if not hasattr(self, 'email_list'):
+ self.load_email_list()
+
+ self.drafting_progressbar = ttk.Progressbar(self, orient='horizontal', maximum=len(self.email_list), length=300)
+ self.drafting_progressbar.grid(column=0, row=4, columnspan=3)
+
+ # Thread allows the UI to continue to work while this runs
+ t = threading.Thread(target=self.email_drafter.generate_drafts, args=(self.email_list, self.template_editor.template_editor.template_editor.template, self.creds, self.drafting_progressbar))
+ t.start()
+
+ def view_errors(self):
+ if len(self.email_drafter.errors) > 1:
+ message = f"There were {len(self.email_drafter.errors)} error while sending E-mails. Would you like to save this to a file, so you can fix them and send them again?"
+ else:
+ message = f"There was {len(self.email_drafter.errors)} errors while sending E-mails. Would you like to save these to a file, so you can fix them and send them again?"
+
+ if messagebox.askyesno("Errors", message):
+ error_output_path = filedialog.asksaveasfilename(title="Select where to save the list of failed E-mails", defaultextension=".json")
+ if error_output_path == None or error_output_path == '':
+ return
+ with open(error_output_path, "w") as error_file:
+ json.dump(self.email_drafter.errors, error_file)
+
+
+def main():
+ """Tcl/Tk based GUI for email-draft-generator"""
+ root = tk.Tk()
+ root.title("E-mail Generator")
+ root.resizable(False, False)
+
+ # App icon
+ if platform.system() == 'Darwin': # Show a different icon on MacOS than anywhere else
+ icon_file = 'assets/icon_macos.png'
+ else:
+ icon_file = 'assets/icon.png'
+
+ if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
+ icon_file = path.join(path.dirname(__file__), icon_file)
+
+ photo = ImageTk.PhotoImage(file=icon_file)
+ root.wm_iconphoto(False, photo) # type: ignore
+
+ app = App(root)
+ app.mainloop()
diff --git a/src/email_draft_generator/gui/template_editor.py b/src/email_draft_generator/gui/template_editor.py
new file mode 100644
index 0000000..7c31f76
--- /dev/null
+++ b/src/email_draft_generator/gui/template_editor.py
@@ -0,0 +1,306 @@
+import os
+import json
+import pathlib
+import json_fix
+import subprocess
+import platform
+
+import tkinter as tk
+from tkinter import ttk
+from tkinter import filedialog
+from tkinter import messagebox
+
+from email_draft_generator.attachment import EmailAttachment
+from email_draft_generator.gui import util
+from email_draft_generator.email_template import EmailTemplate
+
+
+class AttachmentEditorPopup(tk.Toplevel):
+ """An editor window for EmailAttachments."""
+
+ def __init__(self, parent, attachment: EmailAttachment):
+ super().__init__(parent)
+ # TODO: Add a file preview?
+
+ self.wm_title("Attachment Editor: " + attachment.filename)
+
+ # Allow the contents to expand to the size of the frame
+ self.grid_rowconfigure(0, weight=1)
+ self.grid_columnconfigure(0, weight=1)
+
+ self.attachment = attachment
+ self.deleted = False
+
+ # Filename
+ filename_frame = tk.LabelFrame(self, text="File Name", padx=5, pady=5)
+ filename_frame.grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
+ filename_frame.grid_rowconfigure(0, weight=1)
+ filename_frame.grid_columnconfigure(0, weight=1)
+
+ self.filename_textbox = util.SettableEntry(filename_frame)
+ self.filename_textbox.grid(sticky="nsew")
+ self.filename_textbox.set_text(self.attachment.filename)
+
+ buttons_frame = ttk.Frame(self)
+ buttons_frame.grid(row=1, column=0, sticky="ew")
+ ttk.Button(buttons_frame, text="Preview", command=self.open).grid(row=0, column=0)
+ ttk.Button(buttons_frame, text="Delete", command=self.delete).grid(row=0, column=1)
+ ttk.Button(buttons_frame, text="Close", command=self.destroy).grid(row=0, column=2)
+
+ def open(self):
+ if platform.system() == 'Darwin': # macOS
+ subprocess.call(('open', self.attachment.path)) # type: ignore
+ elif platform.system() == 'Windows': # Windows
+ os.startfile(self.attachment.path) # type: ignore
+ else: # linux variants
+ subprocess.call(('xdg-open', self.attachment.path)) # type: ignore
+
+ def delete(self):
+ self.deleted = True
+ self.destroy()
+
+ def show(self):
+ """Shows the window and returns the template."""
+ self.deiconify()
+ self.wait_window()
+ # TODO: Figure out why this doesnt work
+ #self.attachment.filename = self.filename_textbox.get()
+ if not self.deleted:
+ return self.attachment
+ else:
+ return False
+
+
+class AttachmentEditor(tk.Frame):
+ """An editor for a template's EmailAttachments."""
+
+ def __init__(self, parent):
+ super().__init__(parent)
+
+ # Frame to contain attachments
+ self.attachment_frame = tk.LabelFrame(self, text="Attachments", padx=5, pady=5)
+ self.attachment_frame.grid(row=2, column=0, padx=10, pady=10, sticky="nsew")
+
+ self.attachment_frame.grid_rowconfigure(0, weight=1)
+ self.attachment_frame.grid_columnconfigure(0, weight=1)
+
+ ttk.Button(self, text="New Attachment", command=self.new).grid(row=1, column=0)
+
+ def new(self):
+ attachment_path = filedialog.askopenfilename()
+ self.attachments.append(EmailAttachment.from_path(pathlib.Path(attachment_path)))
+ self.set_attachments(self.attachments)
+
+ def set_attachments(self, attachments):
+ self.attachments = attachments
+ # Remove all of the previous attachments fromt the frame
+ for widget in self.attachment_frame.winfo_children():
+ widget.destroy()
+ # Add a widget for each attachment
+ for i, attachment in enumerate(attachments):
+ button = ttk.Button(self.attachment_frame, text=attachment.filename, command=lambda: self.edit_attachment(i))
+ button.grid(row=0, column=i)
+
+ def edit_attachment(self, attachment: int):
+ editor = AttachmentEditorPopup(self, self.attachments[attachment])
+ self.attachments[attachment] = editor.show()
+ if self.attachments[attachment] == False:
+ self.attachments.pop(attachment)
+ self.set_attachments(self.attachments)
+
+ def get(self):
+ return self.attachments
+
+
+class TemplateEditor(tk.Frame):
+ """An editor for EmailTemplates."""
+
+ def __init__(self, parent, template: EmailTemplate | None = None):
+ super().__init__(parent)
+
+ if template == None:
+ self.template = EmailTemplate.get_sample_template()
+ else:
+ self.template = template
+
+ # Subject
+ subject_frame = tk.LabelFrame(self, text="Subject", padx=5, pady=5)
+ subject_frame.grid(row=0, column=0, padx=10, pady=10, sticky="nsew")
+ subject_frame.grid_rowconfigure(0, weight=1)
+ subject_frame.grid_columnconfigure(0, weight=1)
+
+ self.subject_textbox = util.SettableEntry(subject_frame)
+ self.subject_textbox.grid(sticky="nsew")
+
+ # Body
+ body_frame = tk.LabelFrame(self, text="Body", padx=5, pady=5)
+ body_frame.grid(row=1, column=0, padx=10, pady=10, sticky="nsew")
+ body_frame.grid_rowconfigure(0, weight=1)
+ body_frame.grid_columnconfigure(0, weight=1)
+
+ self.body_textbox = util.SettableScrolledText(body_frame)
+ self.body_textbox.grid(sticky="nsew")
+
+ # Attachments
+ self.attachment_editor = AttachmentEditor(self)
+ self.attachment_editor.grid(sticky="nsew")
+
+ self.grid_rowconfigure(0, weight=0)
+ self.grid_rowconfigure(1, weight=1)
+ self.grid_rowconfigure(2, weight=0)
+ self.grid_columnconfigure(0, weight=1)
+
+ self.set_template(self.template)
+
+ def get_template(self):
+ """Returns the current template."""
+ return EmailTemplate(subject=self.subject_textbox.get(), body=self.body_textbox.get("1.0", "end-1c"), attachments=self.attachment_editor.get())
+
+ def set_template(self, template: EmailTemplate):
+ """Sets the template."""
+ self.subject_textbox.set_text(template.subject)
+ self.body_textbox.set_text(template.body)
+ self.attachment_editor.set_attachments(template.attachments)
+ self.template = template
+
+ def check_if_edited(self):
+ """Compares the current template to the saved data."""
+ return self.template != self.get_template()
+
+
+class TemplateEditorWindow(tk.Frame):
+ """An editor for EmailTemplates with an interface to control it."""
+
+ def __init__(self, parent, template: EmailTemplate | None = None, *, popup=False):
+ super().__init__(parent, padx=10, pady=10)
+ self.parent = parent
+ self.popup = popup
+
+ self.parent.wm_title("Template Editor")
+ self.parent.wm_protocol("WM_DELETE_WINDOW", self.quit_with_prompt) # Prompt to save on quit
+
+ # Allow the contents to expand to the size of the frame
+ self.grid_rowconfigure(0, weight=0)
+ self.grid_rowconfigure(1, weight=1)
+ self.grid_columnconfigure(0, weight=1)
+ self.grid(sticky="nsew")
+
+ # Buttons
+ buttons_frame = ttk.Frame(self)
+ buttons_frame.grid(row=0, column=0, sticky="ew")
+
+ if popup:
+ ttk.Button(buttons_frame, text="Save and Return", command=self.save_template).grid(row=0, column=0)
+ ttk.Button(buttons_frame, text="Save As...", command=self.save_template_as).grid(row=0, column=1)
+ ttk.Button(buttons_frame, text="Cancel", command=self.quit_with_prompt).grid(row=0, column=2)
+ else:
+ ttk.Button(buttons_frame, text="Open", command=self.load_template).grid(row=0, column=0)
+ ttk.Button(buttons_frame, text="Save", command=self.save_template).grid(row=0, column=1)
+ ttk.Button(buttons_frame, text="Save As...", command=self.save_template_as).grid(row=0, column=2)
+ ttk.Button(buttons_frame, text="Exit", command=self.quit_with_prompt).grid(row=0, column=3)
+
+ self.template_editor = TemplateEditor(self, template)
+ self.template_editor.grid(row=1, column=0, sticky="nsew")
+
+ def prompt_to_save(self):
+ """Prompt the user to save if the file has been edited.
+
+ Return:
+ `True` if user selected `Yes` or `No` or the file has not been edited, `False` if user selected `Cancel`.
+ """
+ if self.template_editor.check_if_edited():
+ save_prompt = messagebox.askyesnocancel("Unsaved Changes", "You have unsaved changes. Would you like to save them?")
+ if save_prompt == True:
+ self.save_template()
+ elif save_prompt == False:
+ self.template_editor.set_template(self.template_editor.template)
+
+ return save_prompt != None
+ else:
+ return True
+
+ def load_template(self):
+ """Prompts the user to select the template file."""
+ if self.prompt_to_save():
+ self.template_path = filedialog.askopenfilename(title="Select E-mail template", filetypes=[("E-mail template files", ".json")])
+
+ if self.template_path == None or self.template_path == '':
+ return
+
+ # Parse template
+ with open(self.template_path) as template_file:
+ try:
+ self.template_editor.set_template(EmailTemplate.from_file(template_file))
+ except Exception as ex:
+ messagebox.showerror(message=str(ex))
+
+ self.parent.wm_title("Template Editor - " + os.path.basename(self.template_path))
+
+ def save_template(self):
+ """Saves the current template and returns to the main window if it is a popup."""
+ self.template_editor.template = self.template_editor.get_template()
+
+ if not hasattr(self, "template_path"):
+ # If no template file was opened, prompt the user to save to a new file
+ template_path = filedialog.asksaveasfilename(title="Select where to save the template", defaultextension=".json")
+ if template_path == None or template_path == '':
+ if self.popup:
+ self.parent.withdraw()
+ return
+ else:
+ self.template_path = template_path
+ self.parent.wm_title("Template Editor - " + os.path.basename(self.template_path))
+
+ with open(self.template_path, "w") as template_file:
+ json.dump(self.template_editor.template, template_file)
+
+ if self.popup:
+ self.parent.withdraw()
+
+ def save_template_as(self):
+ """Saves the current template as a new file."""
+ self.template_editor.template = self.template_editor.get_template()
+
+ template_path = filedialog.asksaveasfilename(title="Select where to save the template", defaultextension=".json")
+ if template_path == None or template_path == '':
+ return
+ else:
+ self.template_path = template_path
+ self.parent.wm_title("Template Editor - " + os.path.basename(self.template_path))
+
+ self.save_template()
+
+ def quit_with_prompt(self):
+ """Quit, prompting the user to save if the file has been edited."""
+ if self.prompt_to_save():
+ # If the window is a popup, hide it so it can be used again later
+ if self.popup:
+ self.parent.withdraw()
+ else:
+ self.parent.destroy()
+
+
+class TemplateEditorPopup(tk.Toplevel):
+
+ def __init__(self, parent, template: EmailTemplate | None = None):
+ super().__init__(parent)
+ # Allow the contents to expand to the size of the frame
+ self.grid_rowconfigure(0, weight=1)
+ self.grid_columnconfigure(0, weight=1)
+
+ # Template Editor
+ self.template_editor = TemplateEditorWindow(self, template, popup=True)
+ self.template_editor.grid(row=0, column=0, sticky="nsew")
+
+ def show(self):
+ """Shows the window and returns the template."""
+ self.deiconify()
+ self.wait_visibility()
+ return self.template_editor.template_editor.template
+
+
+def main():
+ root = tk.Tk()
+ editor = TemplateEditorWindow(root)
+ editor.pack(expand=True, fill='both')
+ editor.mainloop()
diff --git a/src/email_draft_generator/gui/util.py b/src/email_draft_generator/gui/util.py
new file mode 100644
index 0000000..d044d1d
--- /dev/null
+++ b/src/email_draft_generator/gui/util.py
@@ -0,0 +1,32 @@
+import tkinter as tk
+from tkinter import scrolledtext
+
+
+class SettableText(tk.Text):
+
+ def set_text(self, text):
+ self.clear()
+ self.insert(tk.END, text)
+
+ def clear(self):
+ self.delete("1.0", tk.END)
+
+
+class SettableScrolledText(scrolledtext.ScrolledText):
+
+ def set_text(self, text):
+ self.clear()
+ self.insert(tk.END, text)
+
+ def clear(self):
+ self.delete("1.0", tk.END)
+
+
+class SettableEntry(tk.Entry):
+
+ def set_text(self, text):
+ self.clear()
+ self.insert(0, text)
+
+ def clear(self):
+ self.delete(0, tk.END)
diff --git a/src/email_draft_generator/main.py b/src/email_draft_generator/main.py
new file mode 100644
index 0000000..8b8a724
--- /dev/null
+++ b/src/email_draft_generator/main.py
@@ -0,0 +1,43 @@
+import os
+
+import json
+import argparse
+
+from email_draft_generator import gmail
+from email_draft_generator.email_template import EmailTemplate
+from email_draft_generator.email_drafter import EmailDrafter
+
+
+def main():
+ # Command-line arguments
+ parser = argparse.ArgumentParser(prog='email-generator', description='Generates E-mail drafts from a list of E-mail addresses and uploads them to Gmail.')
+
+ parser.add_argument('-t', '--template', type=argparse.FileType('r'), help='the template file to use')
+ parser.add_argument('infile', type=argparse.FileType('r'), help='the list of e-mail addresses to parse')
+
+ args = parser.parse_args()
+
+ # TODO: Use a keyring for these
+ global_creds_dir = os.path.expanduser("~/.local/share/email-generator/credentials")
+ global_token_path = f"{global_creds_dir}/token.json"
+ global_creds_path = f"{global_creds_dir}/credentials.json"
+
+ # Load the companies from the JSON file
+ print("Processing input data")
+ recipients = json.load(args.infile)
+
+ # Load the template, or use the default if none is provided
+ if args.template:
+ template = EmailTemplate.from_file(args.template)
+ print("Template loaded from file")
+ else:
+ template = EmailTemplate.get_sample_template()
+ print("No template provided! Sample template was used")
+
+ # Authenticate with Google
+ print("Authenticating")
+ creds = gmail.get_creds(global_token_path, global_creds_path)
+
+ print("Generating and uploading E-mails")
+ email_drafter = EmailDrafter()
+ email_drafter.generate_drafts(recipients, template, creds)
diff --git a/txt-to-json.py b/txt-to-json.py
deleted file mode 100644
index 42af71c..0000000
--- a/txt-to-json.py
+++ /dev/null
@@ -1,22 +0,0 @@
-import json
-
-input_file = 'email-template.txt'
-output_file = 'companies.json'
-
-companies = []
-
-try:
- with open(input_file, 'r') as file:
- lines = file.readlines()
- for i in range(0, len(lines), 2):
- company = {
- 'name': lines[i].strip(),
- 'email': lines[i+1].strip()
- }
- companies.append(company)
-except:
- print("invalid input file")
-
-
-with open(output_file, 'w') as file:
- json.dump(companies, file, indent=4)