From fe8b92026afd704cc2b42693331e685f16b7e758 Mon Sep 17 00:00:00 2001 From: Vignesh Rao Date: Sun, 26 May 2024 17:49:29 -0500 Subject: [PATCH] Improve linting, update docs and dependencies --- .github/workflows/markdown-validation.yml | 4 +- .github/workflows/python-publish.yml | 30 +-- .pre-commit-config.yaml | 90 +++++---- doc_generator/conf.py | 40 ++-- docs/genindex.html | 2 + docs/index.html | 22 ++- docs/objects.inv | Bin 1114 -> 1121 bytes docs/searchindex.js | 2 +- gmailconnector/__init__.py | 6 +- gmailconnector/lib/requirements.txt | 2 +- gmailconnector/models/options.py | 4 +- gmailconnector/models/responder.py | 20 +- gmailconnector/read_email.py | 178 ++++++++++-------- gmailconnector/send_email.py | 203 ++++++++++++--------- gmailconnector/send_sms.py | 147 ++++++++------- gmailconnector/sms_deleter.py | 60 +++--- gmailconnector/validator/address.py | 9 +- gmailconnector/validator/domain.py | 19 +- gmailconnector/validator/validate_email.py | 124 +++++++------ release_notes.rst | 5 + test_runner.py | 64 +++++-- 21 files changed, 585 insertions(+), 446 deletions(-) diff --git a/.github/workflows/markdown-validation.yml b/.github/workflows/markdown-validation.yml index 1d80805..c1c29ae 100644 --- a/.github/workflows/markdown-validation.yml +++ b/.github/workflows/markdown-validation.yml @@ -8,8 +8,6 @@ on: jobs: none-shall-pass: - runs-on: - - self-hosted - - Ubuntu + runs-on: thevickypedia-default steps: - uses: thevickypedia/none-shall-pass@v5 diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 360a055..d9d4a8b 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,32 +1,14 @@ -# This workflow will upload a Python Package using Twine when a release is created - name: pypi-publish -# Controls when the workflow will run on: - workflow_dispatch: {} + workflow_dispatch: release: types: [ published ] jobs: - deploy: - runs-on: self-hosted + pypi-publisher: + runs-on: thevickypedia-default steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - name: Create packages - run: python -m build - - name: Run twine check - run: twine check dist/* - - name: Upload to pypi - env: - TWINE_USERNAME: ${{ secrets.PYPI_USER }} - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} - run: twine upload dist/*.whl + - uses: thevickypedia/pypi-publisher@v3 + env: + token: ${{ secrets.PYPI_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d908356..fce71e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,42 +1,56 @@ +--- fail_fast: true -exclude: ^docs/ +exclude: ^(notebooks/|scripts/|.github/|docs/) repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 - hooks: - - id: check-added-large-files - - id: check-ast - - id: check-byte-order-marker - - id: check-builtin-literals - - id: check-case-conflict - - id: check-docstring-first - - id: check-executables-have-shebangs - - id: check-shebang-scripts-are-executable - - id: check-merge-conflict - - id: check-toml - - id: check-vcs-permalinks - - id: check-xml - - id: debug-statements - - id: destroyed-symlinks - - id: detect-aws-credentials - - id: detect-private-key - - id: end-of-file-fixer - - id: fix-byte-order-marker - - id: mixed-line-ending - - id: name-tests-test - - id: requirements-txt-fixer - - id: trailing-whitespace + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-yaml + - id: check-json + - id: check-added-large-files + - id: check-ast + - id: check-byte-order-marker + - id: check-builtin-literals + - id: check-case-conflict + - id: check-docstring-first + - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable + - id: check-merge-conflict + - id: check-toml + - id: check-vcs-permalinks + - id: check-xml + - id: debug-statements + - id: destroyed-symlinks + - id: detect-aws-credentials + - id: detect-private-key + - id: end-of-file-fixer + - id: fix-byte-order-marker + - id: mixed-line-ending + - id: name-tests-test + - id: requirements-txt-fixer + - id: trailing-whitespace - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 - hooks: - - id: isort + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort - - repo: local - hooks: - - id: runbook - name: runbook - entry: /bin/bash gen_docs.sh - language: system - pass_filenames: false - always_run: true + - repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + args: [-j8, '--ignore=F401,W503,E203,E501,F821,E306,E722,N812'] + + - repo: https://github.com/jumanjihouse/pre-commit-hook-yamlfmt + rev: 0.2.3 + hooks: + - id: yamlfmt + + - repo: local + hooks: + - id: runbook + name: runbook + entry: /bin/bash gen_docs.sh + language: system + pass_filenames: false + always_run: true diff --git a/doc_generator/conf.py b/doc_generator/conf.py index 8a0d7e7..7f973d5 100644 --- a/doc_generator/conf.py +++ b/doc_generator/conf.py @@ -13,13 +13,13 @@ import os import sys -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.abspath("..")) # -- Project information ----------------------------------------------------- -project = 'Gmail Connector' -copyright = '2021, Vignesh Rao' -author = 'Vignesh Rao' +project = "Gmail Connector" +copyright = "2021, Vignesh Rao" +author = "Vignesh Rao" # -- General configuration --------------------------------------------------- @@ -27,50 +27,52 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.napoleon', # certain styles of doc strings - 'sphinx.ext.autodoc', # generates from doc strings - 'recommonmark', # supports markdown integration + "sphinx.ext.napoleon", # certain styles of doc strings + "sphinx.ext.autodoc", # generates from doc strings + "recommonmark", # supports markdown integration ] # Exclude private members in the docs # https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#confval-autodoc_default_options -autodoc_default_options = {"members": True, "undoc-members": True, "private-members": False} +autodoc_default_options = { + "members": True, + "undoc-members": True, + "private-members": False, +} # https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#configuration napoleon_google_docstring = True napoleon_use_param = False # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # https://www.sphinx-doc.org/en/master/usage/theming.html#builtin-themes -html_theme = 'classic' -html_theme_options = { - "body_max_width": "80%" -} +html_theme = "classic" +html_theme_options = {"body_max_width": "80%"} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # Add docstrings from __init__ method # Reference: https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#confval-autoclass_content -autoclass_content = 'both' +autoclass_content = "both" # Add support to mark down files in sphinx documentation # Reference: https://www.sphinx-doc.org/en/1.5.3/markdown.html source_suffix = { - '.rst': 'restructuredtext', - '.txt': 'markdown', - '.md': 'markdown', + ".rst": "restructuredtext", + ".txt": "markdown", + ".md": "markdown", } diff --git a/docs/genindex.html b/docs/genindex.html index be678e0..ecc5a80 100644 --- a/docs/genindex.html +++ b/docs/genindex.html @@ -107,6 +107,8 @@

C

  • Condition (class in gmailconnector.models.options)
  • count (gmailconnector.models.responder.Response property) +
  • +
  • create_connection() (gmailconnector.send_email.SendEmail method)
  • create_ssl_connection() (gmailconnector.read_email.ReadEmail method) diff --git a/docs/index.html b/docs/index.html index 94d4978..fd9af22 100644 --- a/docs/index.html +++ b/docs/index.html @@ -61,7 +61,7 @@

    Welcome to Gmail Connector’s documentation!

    Send SMS

    -class gmailconnector.send_sms.SendSMS(**kwargs: Unpack)
    +class gmailconnector.send_sms.SendSMS

    Initiates Messenger object to send an SMS to a phone number using SMS gateway provided by the mobile carrier.

    >>> SendSMS
     
    @@ -201,7 +201,7 @@

    Welcome to Gmail Connector’s documentation!

    Send Email

    -class gmailconnector.send_email.SendEmail(**kwargs: Unpack)
    +class gmailconnector.send_email.SendEmail

    Initiates Emailer object to send an email.

    >>> SendEmail
     
    @@ -232,6 +232,12 @@

    Welcome to Gmail Connector’s documentation! +
    +create_connection() None
    +

    Creates SSL/TLS connection based on the request parameter.

    +

    +
    create_ssl_connection(host: str, timeout: Union[int, float]) None
    @@ -276,7 +282,7 @@

    Welcome to Gmail Connector’s documentation!
    -send_email(subject: str, recipient: Union[str, list], sender: str = 'GmailConnector', body: str = None, html_body: str = None, attachment: Union[str, list] = None, filename: Union[str, list] = None, custom_attachment: Dict[Union[str, PathLike], str] = None, cc: Union[str, list] = None, bcc: Union[str, list] = None, fail_if_attach_fails: bool = True) Response
    +send_email(subject: str, recipient: Union[str, list], sender: str = 'GmailConnector', body: Optional[str] = None, html_body: Optional[str] = None, attachment: Optional[Union[str, list]] = None, filename: Optional[Union[str, list]] = None, custom_attachment: Optional[Dict[Union[str, PathLike], str]] = None, cc: Optional[Union[str, list]] = None, bcc: Optional[Union[str, list]] = None, fail_if_attach_fails: bool = True) Response

    Initiates a TLS connection and sends the email.

    Parameters:
    @@ -316,7 +322,7 @@

    Welcome to Gmail Connector’s documentation!

    Read Email

    -class gmailconnector.read_email.ReadEmail(**kwargs: Unpack)
    +class gmailconnector.read_email.ReadEmail

    Initiates Emailer object to authenticate and yield the emails according the conditions/filters.

    >>> ReadEmail
     
    @@ -355,7 +361,7 @@

    Welcome to Gmail Connector’s documentation!
    -create_ssl_connection(gmail_host: str, timeout: Union[int, float]) None
    +create_ssl_connection() None

    Creates an SSL connection to gmail’s SSL server.

    @@ -424,7 +430,7 @@

    Welcome to Gmail Connector’s documentation!

    Validator

    -gmailconnector.validator.validate_email.validate_email(email_address: str, timeout: ~typing.Union[int, float] = 5, sender: str = None, debug: bool = False, smtp_check: bool = True, logger: ~logging.Logger = <Logger validator (DEBUG)>) Response
    +gmailconnector.validator.validate_email.validate_email(email_address: str, timeout: ~typing.Union[int, float] = 5, sender: ~typing.Optional[str] = None, debug: bool = False, smtp_check: bool = True, logger: ~logging.Logger = <Logger validator (DEBUG)>) Response

    Validates email address deliver-ability using SMTP.

    Parameters:
    @@ -634,7 +640,7 @@

    Welcome to Gmail Connector’s documentation!
    -class gmailconnector.models.config.Encryption(value, names=None, *, module=None, qualname=None, type=None, start=1, boundary=None)
    +class gmailconnector.models.config.Encryption(value)

    Enum wrapper for TLS and SSL encryption.

    >>> Encryption
     
    @@ -850,7 +856,7 @@

    Welcome to Gmail Connector’s documentation!
    -class gmailconnector.models.options.Folder(value, names=None, *, module=None, qualname=None, type=None, start=1, boundary=None)
    +class gmailconnector.models.options.Folder(value)

    Wrapper for folders to choose emails from.

    diff --git a/docs/objects.inv b/docs/objects.inv index 4e404760c4572318c0f7a50ea24502c942bc7a3b..08f2939a83cca0b54bbe58cc0e783a47ff7de9d0 100644 GIT binary patch delta 1008 zcmVEA4e9G)Y|OrHEI=uprZ(=V}DT!KS9r=&Ka?99UyTV z1En~jlTXf2G$<%$&W#gVp&|R8#X(z#jBL7)<04xN1`XLa71P#1gq8v%$D4d}&8iSnPLol_B%E8pcGse`}hdDq!vcjgU-_~9Uq zitKe_F5Uu}5$rplBC!%@nVX(Ew_I22eWGmMB3sKq%2rZ?Nj-uQe@B*#x<3eQ^xb0c zCy#zWl{V+q`{NcssFW!)nLwqaRRtUernD=33pgyG1%FUDq*kS~wBjPFI(tDNRw&kQ zni||e^8`>mq(U;N-GB34Fi_4HCh?pPV;K^)#=$*#gA5pMXz45lNz{!tjz-=%ZeeP# zPZ%HzI0Lc`tx-6R8k49V1IpLN_7DZ~wNc}fIV53};Ex2d?b=r-^W^`vf3kpp@bwSx zkDkWp27in@C!m;aL*gK?#%l}$>zfVoyiR%+K>9n+TvBD4Zm2vzD-j!?FfXfez*1=SnJ z7~Ti+Y8)N-$(Q8*ab@6_yyFAENE-ql74+AD(SMor8q`UP2(4S`L_>`$YJcsf6{G@h z5@j{%NTuN2JV!>^sxZ~ND7Lnd8R|XzdHCzu%Iv)IOw7z{5=s&+0Mioa8aP|VNa0NW3#xi~i{gy^IZ};!?+^!Q3H8`3YF-yR85Y8_GXX&bx1G>(~7S zB7fA5?;F23aP9x8Rh#A~#8E@AfR|6P5xi-3v5OxFeMM293Ij}=@Wt$Ig`8M$ zVi5c3Xo*6G75bn}P2JX*kfURVsG*T()tjHjrAy>})^6u}^v^k;Pc!) zZl-Pn_NvF9;Ac@o=Jf3g{`==&n|X(}_995T5&yFB3$XCc=KOGJ2+&p8RI#pKZ z)JhAMCM|Owj(?~wF;i7_iJ2;^b0(IqE>Y8kbxuW4u6&n&qz?X~=Ur!y-I+HS;)jDc zDzev!xp)gK4KKd~DiSMkmbvMvbIWy7?-OP77TMYoglGjdnA9T}{&!@_sQZJ^M&B(4 zfAZ)DRBLlyy+3XNgbI-|lL=G_TGzmEU`o5fw}8U}T7LkALuyqzODo2ss|b4bD{!5;}^+qJJw=E?tS|6~CH;p-pP zA3crH4SyJSPC%8q4T*!m8m}=3tZz2R^E&BS0O{{YBk~#TTDuBnfU*KEiL)*NCtp7V z%2uY|k~g8PJ9wGkr9Vp}=W#_Vz12ZsZC~YfJ^8ATEkbK>1y_|$<#1&?iRQyhQ&7Ee zjNyGCug1}VpL|K~A6Eu`$vZyqi?r0hzXo-}B7Z_*hH!s#b-m-bJy}MkbW^?C0TcQ!A6{$}=&Mu1P3Kv;a&?pljf4!S7^9J6xM0Dvm2> z^3?z4Lrr&QuonH(7kU{ROvR<_OoO>YP~H=it?#x1IBY2YL>cQog{@!r7l=^my`OmZ zt$+XP9Ky8!r&ev6pAbh4!2({soJR1b*~Kn?AoK-*JQW5?ZNdkxyA^U`!KsAUFFi{X zGOW-CZ6tMDV?vIO9ioOto>gyt8ka7S_gTB0@0Ei&0>=3MP@xL8{0Qmbmq%Z0T;`-z zXX@AO`={MDvyQ!Ey38KlX2H_;$UgRf$70JXH#MlAzoz3~-(h#7#uy*0?SFJyB`*QZGs^;Dn Xs3{L~$6yOSd1(LQ%csr%yvtF bool: bool: ``True`` or ``False`` based on the arg value received. """ - return self.raw.get('ok') + return self.raw.get("ok") @property def status(self) -> int: @@ -35,7 +35,7 @@ def status(self) -> int: int: ``HTTP`` status code as received. """ - return self.raw.get('status') + return self.raw.get("status") @property def body(self) -> str: @@ -45,7 +45,7 @@ def body(self) -> str: str: Returns the message as received. """ - return self.raw.get('body') + return self.raw.get("body") def json(self) -> dict: """Returns a dictionary of the argument that was received during class initialization. @@ -64,7 +64,7 @@ def count(self) -> int: int: Returns the number of emails. """ - return self.raw.get('count') + return self.raw.get("count") @property def extra(self) -> Any: @@ -74,7 +74,7 @@ def extra(self) -> Any: Any: Returns information as received. """ - return self.raw.get('extra') + return self.raw.get("extra") class Email: @@ -86,8 +86,8 @@ def __init__(self, dictionary: dict): Args: dictionary: Takes the dictionary to be converted as an argument. """ - self.sender: str = dictionary['sender'] - self.sender_email: str = dictionary['sender_email'] - self.subject: str = dictionary['subject'] - self.date_time: Union[str, 'datetime'] = dictionary['date_time'] - self.body: str = dictionary['body'] + self.sender: str = dictionary["sender"] + self.sender_email: str = dictionary["sender_email"] + self.subject: str = dictionary["subject"] + self.date_time: Union[str, "datetime"] = dictionary["date_time"] + self.body: str = dictionary["body"] diff --git a/gmailconnector/read_email.py b/gmailconnector/read_email.py index ff61208..e7cfe1a 100644 --- a/gmailconnector/read_email.py +++ b/gmailconnector/read_email.py @@ -27,7 +27,7 @@ class ReadEmail: LOCAL_TIMEZONE = datetime.now(timezone.utc).astimezone().tzinfo - def __init__(self, **kwargs: 'Unpack[IngressConfig]'): + def __init__(self, **kwargs: "Unpack[IngressConfig]"): """Loads all the necessary args, creates a connection with Gmail host to read emails from the chosen folder. Keyword Args: @@ -46,14 +46,14 @@ def __init__(self, **kwargs: 'Unpack[IngressConfig]'): self.error, self.mail = None, None self._authenticated = False self.env = IngressConfig(**kwargs) - self.create_ssl_connection(gmail_host=self.env.gmail_host, timeout=self.env.timeout) + self.create_ssl_connection() - def create_ssl_connection(self, - gmail_host: str, - timeout: Union[int, float]) -> None: + def create_ssl_connection(self) -> None: """Creates an SSL connection to gmail's SSL server.""" try: - self.mail = imaplib.IMAP4_SSL(host=gmail_host, port=993, timeout=timeout) + self.mail = imaplib.IMAP4_SSL( + host=self.env.gmail_host, port=993, timeout=self.env.timeout + ) except socket.error as error: self.error = error.__str__() @@ -66,31 +66,34 @@ def authenticate(self) -> Response: A custom response object with properties: ok, status and body to the user. """ if self.mail is None: - return Response(dictionary={ - 'ok': False, - 'status': 408, - 'body': self.error or "failed to create a connection with gmail's SMTP server" - }) + return Response( + dictionary={ + "ok": False, + "status": 408, + "body": self.error + or "failed to create a connection with gmail's SMTP server", + } + ) try: self.mail.login(user=self.env.gmail_user, password=self.env.gmail_pass) self.mail.list() # list all the folders within your mailbox (like inbox, sent, drafts, etc) self.mail.select(self.env.folder) self._authenticated = True - return Response(dictionary={ - 'ok': True, - 'status': 200, - 'body': 'authentication success' - }) + return Response( + dictionary={"ok": True, "status": 200, "body": "authentication success"} + ) except Exception as error: self.error = error.__str__() - return Response(dictionary={ - 'ok': False, - 'status': 401, - 'body': 'authentication failed' - }) - - def instantiate(self, - filters: Union[Iterable[Category.__str__], Iterable[Condition.__str__]] = "UNSEEN") -> Response: + return Response( + dictionary={"ok": False, "status": 401, "body": "authentication failed"} + ) + + def instantiate( + self, + filters: Union[ + Iterable[Category.__str__], Iterable[Condition.__str__] + ] = "UNSEEN", + ) -> Response: """Searches the number of emails for the category received and forms. Args: @@ -108,36 +111,35 @@ def instantiate(self, if not status.ok: return status if type(filters) in (list, tuple): - filters = ' '.join(filters) + filters = " ".join(filters) return_code, messages = self.mail.search(None, filters) - if return_code != 'OK': - return Response(dictionary={ - 'ok': False, - 'status': 404, - 'body': 'Unable to read emails.' - }) + if return_code != "OK": + return Response( + dictionary={ + "ok": False, + "status": 404, + "body": "Unable to read emails.", + } + ) num = len(messages[0].split()) if not num: - return Response(dictionary={ - 'ok': False, - 'status': 204, - 'body': f'No emails found in {self.env.gmail_user} [{self.env.folder}] ' - f'for the filter(s) {filters.lower()!r}', - 'count': num - }) - - if return_code == 'OK': - return Response(dictionary={ - 'ok': True, - 'status': 200, - 'body': messages, - 'count': num - }) - - def get_info(self, - response_part: tuple, - dt_flag: bool) -> Email: + return Response( + dictionary={ + "ok": False, + "status": 204, + "body": f"No emails found in {self.env.gmail_user} [{self.env.folder}] " + f"for the filter(s) {filters.lower()!r}", + "count": num, + } + ) + + if return_code == "OK": + return Response( + dictionary={"ok": True, "status": 200, "body": messages, "count": num} + ) + + def get_info(self, response_part: tuple, dt_flag: bool) -> Email: """Extracts sender, subject, body and time received from response part. Args: @@ -149,21 +151,26 @@ def get_info(self, Email object with information. """ original_email = email.message_from_bytes(response_part[1]) - if received := original_email.get('Received'): - date = received.split(';')[-1].strip() + if received := original_email.get("Received"): + date = received.split(";")[-1].strip() else: - date = original_email.get('Date') - if '(PDT)' in date: + date = original_email.get("Date") + if "(PDT)" in date: datetime_obj = datetime.strptime(date, "%a, %d %b %Y %H:%M:%S -0700 (PDT)") - elif '(PST)' in date: + elif "(PST)" in date: datetime_obj = datetime.strptime(date, "%a, %d %b %Y %H:%M:%S -0800 (PST)") else: datetime_obj = datetime.now() - from_ = original_email['From'].split(' <') - sub = make_header(decode_header(original_email['Subject'])) \ - if original_email['Subject'] else None + from_ = original_email["From"].split(" <") + sub = ( + make_header(decode_header(original_email["Subject"])) + if original_email["Subject"] + else None + ) # Converts pacific time to local timezone as the default is pacific - local_time = datetime_obj.replace(tzinfo=pytz.timezone('US/Pacific')).astimezone(tz=self.LOCAL_TIMEZONE) + local_time = datetime_obj.replace( + tzinfo=pytz.timezone("US/Pacific") + ).astimezone(tz=self.LOCAL_TIMEZONE) if dt_flag: received_date = local_time.strftime("%Y-%m-%d") current_date_ = datetime.today().date() @@ -176,9 +183,11 @@ def get_info(self, receive = local_time.strftime("on %A, %B %d, at %I:%M %p") else: receive = local_time - if original_email.get_content_type() == "text/plain": # ignore attachments and html + if ( + original_email.get_content_type() == "text/plain" + ): # ignore attachments and html body = original_email.get_payload(decode=True) - body = body.decode('utf-8') + body = body.decode("utf-8") else: body = "" for payload in original_email.get_payload(): @@ -191,25 +200,38 @@ def get_info(self, decoded = base64.b64decode(payload) except binascii.Error: try: - decoded = payload.decode() # encoding is unknown at this point so default to UTF-8 + decoded = ( + payload.decode() + ) # encoding is unknown at this point so default to UTF-8 except UnicodeDecodeError: - warnings.warn( - "Unknown encoding type for payload" - ) + warnings.warn("Unknown encoding type for payload") continue body += decoded else: - warnings.warn( - f"Unsupported payload type: {type(payload)}" - ) + warnings.warn(f"Unsupported payload type: {type(payload)}") if len(from_) == 1: - return Email(dictionary=dict(sender=None, sender_email=from_[0].lstrip('<').rstrip('>'), - subject=sub, date_time=receive, body=body)) - return Email(dictionary=dict(sender=from_[0], sender_email=from_[1].rstrip('>'), - subject=sub, date_time=receive, body=body)) - - def read_mail(self, messages: Union[list, str], - humanize_datetime: bool = False) -> Generator[Email]: + return Email( + dictionary=dict( + sender=None, + sender_email=from_[0].lstrip("<").rstrip(">"), + subject=sub, + date_time=receive, + body=body, + ) + ) + return Email( + dictionary=dict( + sender=from_[0], + sender_email=from_[1].rstrip(">"), + subject=sub, + date_time=receive, + body=body, + ) + ) + + def read_mail( + self, messages: Union[list, str], humanize_datetime: bool = False + ) -> Generator[Email]: """Yield emails matching the filters' criteria. Args: @@ -221,10 +243,12 @@ def read_mail(self, messages: Union[list, str], A custom response object with properties: ok, status and body to the user. """ for nm in messages[0].split(): - dummy, data = self.mail.fetch(nm, '(RFC822)') + dummy, data = self.mail.fetch(nm, "(RFC822)") for each_response in data: if isinstance(each_response, tuple): - yield self.get_info(response_part=each_response, dt_flag=humanize_datetime) + yield self.get_info( + response_part=each_response, dt_flag=humanize_datetime + ) else: if self.mail: self.mail.close() diff --git a/gmailconnector/send_email.py b/gmailconnector/send_email.py index 510f5ce..de01c47 100644 --- a/gmailconnector/send_email.py +++ b/gmailconnector/send_email.py @@ -28,7 +28,7 @@ class SendEmail: """ - def __init__(self, **kwargs: 'Unpack[EgressConfig]'): + def __init__(self, **kwargs: "Unpack[EgressConfig]"): """Loads all the necessary args, creates a connection with Gmail host based on chosen encryption type. Keyword Args: @@ -42,10 +42,18 @@ def __init__(self, **kwargs: 'Unpack[EgressConfig]'): self.env = EgressConfig(**kwargs) self._failed_attachments = {"FILE NOT FOUND": [], "FILE SIZE OVER 25 MB": []} self._authenticated = False + self.create_connection() + + def create_connection(self) -> None: + """Creates SSL/TLS connection based on the request parameter.""" if self.env.encryption == Encryption.TLS: - self.create_tls_connection(host=self.env.gmail_host, timeout=self.env.timeout) + self.create_tls_connection( + host=self.env.gmail_host, timeout=self.env.timeout + ) else: - self.create_ssl_connection(host=self.env.gmail_host, timeout=self.env.timeout) + self.create_ssl_connection( + host=self.env.gmail_host, timeout=self.env.timeout + ) def create_ssl_connection(self, host: str, timeout: Union[int, float]) -> None: """Create a connection using SSL encryption.""" @@ -54,9 +62,7 @@ def create_ssl_connection(self, host: str, timeout: Union[int, float]) -> None: except (smtplib.SMTPException, socket.error) as error: self.error = error.__str__() - def create_tls_connection(self, - host: str, - timeout: Union[int, float]) -> None: + def create_tls_connection(self, host: str, timeout: Union[int, float]) -> None: """Create a connection using TLS encryption.""" try: self.server = smtplib.SMTP(host=host, port=587, timeout=timeout) @@ -73,46 +79,45 @@ def authenticate(self) -> Response: A custom response object with properties: ok, status and body to the user. """ if self.server is None: - return Response(dictionary={ - 'ok': False, - 'status': 408, - 'body': self.error or "failed to create a connection with gmail's SMTP server" - }) + return Response( + dictionary={ + "ok": False, + "status": 408, + "body": self.error + or "failed to create a connection with gmail's SMTP server", + } + ) try: self.server.login(user=self.env.gmail_user, password=self.env.gmail_pass) self._authenticated = True - return Response(dictionary={ - 'ok': True, - 'status': 200, - 'body': 'authentication success' - }) + return Response( + dictionary={"ok": True, "status": 200, "body": "authentication success"} + ) except smtplib.SMTPAuthenticationError: - return Response(dictionary={ - 'ok': False, - 'status': 401, - 'body': 'authentication failed' - }) + return Response( + dictionary={"ok": False, "status": 401, "body": "authentication failed"} + ) except smtplib.SMTPException as error: - return Response(dictionary={ - 'ok': False, - 'status': 503, - 'body': error.__str__() - }) + return Response( + dictionary={"ok": False, "status": 503, "body": error.__str__()} + ) def __del__(self): """Destructor has been called to close the connection and logout.""" if self.server: self.server.close() - def multipart_message(self, - subject: str, - recipient: Union[str, List[str]], - sender: str, - body: str, - html_body: str, - attachments: list, - filenames: list, - cc: Union[str, List[str]]) -> MIMEMultipart: + def multipart_message( + self, + subject: str, + recipient: Union[str, List[str]], + sender: str, + body: str, + html_body: str, + attachments: list, + filenames: list, + cc: Union[str, List[str]], + ) -> MIMEMultipart: """Creates a multipart message with subject, body, from and to address, and attachment if filename is passed. Args: @@ -136,58 +141,66 @@ def multipart_message(self, cc = [cc] if cc and isinstance(cc, str) else cc msg = MIMEMultipart() - msg['Subject'] = subject - msg['From'] = f"{sender} <{self.env.gmail_user}>" - msg['To'] = ','.join(recipient) + msg["Subject"] = subject + msg["From"] = f"{sender} <{self.env.gmail_user}>" + msg["To"] = ",".join(recipient) if cc: - msg['Cc'] = ','.join(cc) + msg["Cc"] = ",".join(cc) if body: - msg.attach(payload=MIMEText(body, 'plain')) + msg.attach(payload=MIMEText(body, "plain")) if html_body: - msg.attach(payload=MIMEText(html_body, 'html')) + msg.attach(payload=MIMEText(html_body, "html")) for index, attachment_ in enumerate(attachments): - file_type = attachment_.split('.')[-1] + file_type = attachment_.split(".")[-1] try: filename = filenames[index] except IndexError: filename = None - if filename and '.' in filename: # filename is passed with an extn + if filename and "." in filename: # filename is passed with an extn pass - elif filename and '.' in attachment_: # file name's extn is got from attachment name - filename = f'{filename}.{file_type}' - elif filename: # filename is passed without an extn so proceeding with the same + elif ( + filename and "." in attachment_ + ): # file name's extn is got from attachment name + filename = f"{filename}.{file_type}" + elif ( + filename + ): # filename is passed without an extn so proceeding with the same pass else: - filename = attachment_.split(os.path.sep)[-1].strip() # rips path from attachment as filename + filename = attachment_.split(os.path.sep)[ + -1 + ].strip() # rips path from attachment as filename if not os.path.isfile(attachment_): self._failed_attachments["FILE NOT FOUND"].append(filename) continue - if os.path.getsize(attachment_) / 1e+6 > 25: + if os.path.getsize(attachment_) / 1e6 > 25: self._failed_attachments["FILE SIZE OVER 25 MB"].append(filename) continue - with open(attachment_, 'rb') as file: + with open(attachment_, "rb") as file: attribute = MIMEApplication(file.read(), _subtype=file_type) - attribute.add_header('Content-Disposition', 'attachment', filename=filename) + attribute.add_header("Content-Disposition", "attachment", filename=filename) msg.attach(payload=attribute) return msg - def send_email(self, - subject: str, - recipient: Union[str, list], - sender: str = 'GmailConnector', - body: str = None, - html_body: str = None, - attachment: Union[str, list] = None, - filename: Union[str, list] = None, - custom_attachment: Dict[Union[str, os.PathLike], str] = None, - cc: Union[str, list] = None, - bcc: Union[str, list] = None, - fail_if_attach_fails: bool = True) -> Response: + def send_email( + self, + subject: str, + recipient: Union[str, list], + sender: str = "GmailConnector", + body: str = None, + html_body: str = None, + attachment: Union[str, list] = None, + filename: Union[str, list] = None, + custom_attachment: Dict[Union[str, os.PathLike], str] = None, + cc: Union[str, list] = None, + bcc: Union[str, list] = None, + fail_if_attach_fails: bool = True, + ) -> Response: """Initiates a TLS connection and sends the email. Args: @@ -218,19 +231,37 @@ def send_email(self, attachments = list(custom_attachment.keys()) filenames = list(custom_attachment.values()) else: - attachments = [attachment] if isinstance(attachment, str) else attachment if attachment else [] - filenames = [filename] if isinstance(filename, str) else filename if filename else [] + attachments = ( + [attachment] + if isinstance(attachment, str) + else attachment if attachment else [] + ) + filenames = ( + [filename] + if isinstance(filename, str) + else filename if filename else [] + ) - msg = self.multipart_message(subject=subject, sender=sender, recipient=recipient, attachments=attachments, - body=body, html_body=html_body, cc=cc, filenames=filenames) + msg = self.multipart_message( + subject=subject, + sender=sender, + recipient=recipient, + attachments=attachments, + body=body, + html_body=html_body, + cc=cc, + filenames=filenames, + ) - unattached = {k: ', '.join(v) for k, v in self._failed_attachments.items() if v} + unattached = {k: ", ".join(v) for k, v in self._failed_attachments.items() if v} if fail_if_attach_fails and unattached: - return Response(dictionary={ - 'ok': False, - 'status': 422, - 'body': f"Email was not sent. Unattached: {unattached!r}" - }) + return Response( + dictionary={ + "ok": False, + "status": 422, + "body": f"Email was not sent. Unattached: {unattached!r}", + } + ) recipients = [recipient] if isinstance(recipient, str) else recipient if cc: @@ -240,9 +271,7 @@ def send_email(self, for i in range(3): try: self.server.sendmail( - from_addr=sender, - to_addrs=recipients, - msg=msg.as_string() + from_addr=sender, to_addrs=recipients, msg=msg.as_string() ) break except smtplib.SMTPServerDisconnected as err: @@ -250,14 +279,18 @@ def send_email(self, raise err continue if unattached: - return Response(dictionary={ - 'ok': True, - 'status': 206, - 'body': f"Email has been sent to {recipient!r}. Unattached: {unattached!r}." - }) + return Response( + dictionary={ + "ok": True, + "status": 206, + "body": f"Email has been sent to {recipient!r}. Unattached: {unattached!r}.", + } + ) else: - return Response(dictionary={ - 'ok': True, - 'status': 200, - 'body': f"Email has been sent to {recipient!r}" - }) + return Response( + dictionary={ + "ok": True, + "status": 200, + "body": f"Email has been sent to {recipient!r}", + } + ) diff --git a/gmailconnector/send_sms.py b/gmailconnector/send_sms.py index bf66a0c..3f262ed 100644 --- a/gmailconnector/send_sms.py +++ b/gmailconnector/send_sms.py @@ -19,7 +19,7 @@ class SendSMS: """ - def __init__(self, **kwargs: 'Unpack[EgressConfig]'): + def __init__(self, **kwargs: "Unpack[EgressConfig]"): """Loads all the necessary args, creates a connection with Gmail host based on chosen encryption type. Keyword Args: @@ -33,22 +33,22 @@ def __init__(self, **kwargs: 'Unpack[EgressConfig]'): self.env = EgressConfig(**kwargs) self._authenticated = False if self.env.encryption == Encryption.TLS: - self.create_tls_connection(host=self.env.gmail_host, timeout=self.env.timeout) + self.create_tls_connection( + host=self.env.gmail_host, timeout=self.env.timeout + ) else: - self.create_ssl_connection(host=self.env.gmail_host, timeout=self.env.timeout) + self.create_ssl_connection( + host=self.env.gmail_host, timeout=self.env.timeout + ) - def create_ssl_connection(self, - host: str, - timeout: Union[int, float]) -> None: + def create_ssl_connection(self, host: str, timeout: Union[int, float]) -> None: """Create a connection using SSL encryption.""" try: self.server = smtplib.SMTP_SSL(host=host, port=465, timeout=timeout) except (smtplib.SMTPException, socket.error) as error: self.error = error.__str__() - def create_tls_connection(self, - host: str, - timeout: Union[int, float]) -> None: + def create_tls_connection(self, host: str, timeout: Union[int, float]) -> None: """Create a connection using TLS encryption.""" try: self.server = smtplib.SMTP(host=host, port=587, timeout=timeout) @@ -65,44 +65,43 @@ def authenticate(self) -> Response: A custom response object with properties: ok, status and body to the user. """ if self.server is None: - return Response(dictionary={ - 'ok': False, - 'status': 408, - 'body': self.error or "failed to create a connection with gmail's SMTP server" - }) + return Response( + dictionary={ + "ok": False, + "status": 408, + "body": self.error + or "failed to create a connection with gmail's SMTP server", + } + ) try: self.server.login(user=self.env.gmail_user, password=self.env.gmail_pass) self._authenticated = True - return Response(dictionary={ - 'ok': True, - 'status': 200, - 'body': 'authentication success' - }) + return Response( + dictionary={"ok": True, "status": 200, "body": "authentication success"} + ) except smtplib.SMTPAuthenticationError: - return Response(dictionary={ - 'ok': False, - 'status': 401, - 'body': 'authentication failed' - }) + return Response( + dictionary={"ok": False, "status": 401, "body": "authentication failed"} + ) except smtplib.SMTPException as error: - return Response(dictionary={ - 'ok': False, - 'status': 503, - 'body': error.__str__() - }) + return Response( + dictionary={"ok": False, "status": 503, "body": error.__str__()} + ) def __del__(self): """Destructor has been called to close the connection and logout.""" if self.server: self.server.close() - def send_sms(self, - message: str, - phone: str = None, - country_code: str = None, - subject: str = None, - sms_gateway: SMSGateway = None, - delete_sent: bool = False) -> Response: + def send_sms( + self, + message: str, + phone: str = None, + country_code: str = None, + subject: str = None, + sms_gateway: SMSGateway = None, + delete_sent: bool = False, + ) -> Response: """Initiates an SMTP connection and sends a text message through SMS gateway of destination number. Args: @@ -141,44 +140,54 @@ def send_sms(self, raise ValueError( f"\n\tcountry code should match the pattern {COUNTRY_CODE.pattern}" ) - body = f'\n{message}'.encode('ascii', 'ignore').decode('ascii') + body = f"\n{message}".encode("ascii", "ignore").decode("ascii") subject = subject or f"Message from {self.env.gmail_user}" if not self._authenticated: status = self.authenticate if not status.ok: return status - message = (f"From: {self.env.gmail_user}\n" + f"To: {to}\n" + f"Subject: {subject}\n" + body) - if len(message) > 428: - return Response(dictionary={ - 'ok': False, - 'status': 413, - 'body': f'Payload length: {len(message):,}, which is more than the optimal size: 428. ' - f'Message length: {len(body):,}' - }) - - self.server.sendmail( - from_addr=self.env.gmail_user, - to_addrs=to, - msg=message + message = ( + f"From: {self.env.gmail_user}\n" + + f"To: {to}\n" + + f"Subject: {subject}\n" + + body ) + if len(message) > 428: + return Response( + dictionary={ + "ok": False, + "status": 413, + "body": f"Payload length: {len(message):,}, which is more than the optimal size: 428. " + f"Message length: {len(body):,}", + } + ) + + self.server.sendmail(from_addr=self.env.gmail_user, to_addrs=to, msg=message) if delete_sent: - if delete_response := DeleteSent(username=self.env.gmail_user, password=self.env.gmail_pass, - subject=subject, body=body, to=to).delete_sent(): - return Response(dictionary={ - 'ok': True, - 'status': 200, - 'body': f'SMS has been sent to {to}', - 'extra': delete_response - }) - return Response(dictionary={ - 'ok': True, - 'status': 206, - 'body': f'SMS has been sent to {to}', - 'extra': 'Failed to locate and delete the SMS from Sent Mail.' - }) - return Response(dictionary={ - 'ok': True, - 'status': 200, - 'body': f'SMS has been sent to {to}' - }) + if delete_response := DeleteSent( + username=self.env.gmail_user, + password=self.env.gmail_pass, + subject=subject, + body=body, + to=to, + ).delete_sent(): + return Response( + dictionary={ + "ok": True, + "status": 200, + "body": f"SMS has been sent to {to}", + "extra": delete_response, + } + ) + return Response( + dictionary={ + "ok": True, + "status": 206, + "body": f"SMS has been sent to {to}", + "extra": "Failed to locate and delete the SMS from Sent Mail.", + } + ) + return Response( + dictionary={"ok": True, "status": 200, "body": f"SMS has been sent to {to}"} + ) diff --git a/gmailconnector/sms_deleter.py b/gmailconnector/sms_deleter.py index 8009e87..129a578 100644 --- a/gmailconnector/sms_deleter.py +++ b/gmailconnector/sms_deleter.py @@ -24,11 +24,11 @@ def __init__(self, **kwargs): body: Body of the email to be deleted. to: To address of the email to be deleted. """ - self.username = kwargs.get('username') - self.password = kwargs.get('password') - self.subject = kwargs.get('subject') - self.body = kwargs.get('body') - self.to = kwargs.get('to') + self.username = kwargs.get("username") + self.password = kwargs.get("password") + self.subject = kwargs.get("subject") + self.body = kwargs.get("body") + self.to = kwargs.get("to") self.mail = None self.error = None self.create_ssl_connection() @@ -36,34 +36,44 @@ def __init__(self, **kwargs): def create_ssl_connection(self) -> None: """Creates a connection using SSL encryption and selects the sent folder.""" try: - self.mail = imaplib.IMAP4_SSL('imap.gmail.com') + self.mail = imaplib.IMAP4_SSL("imap.gmail.com") self.mail.login(user=self.username, password=self.password) self.mail.list() self.mail.select(Folder.sent) except Exception as error: self.error = error.__str__() - def thread_executor(self, - item_id: Union[bytes, str]) -> Dict[str, str]: + def thread_executor(self, item_id: Union[bytes, str]) -> Dict[str, str]: """Gets invoked in multiple threads, to set the flag as ``Deleted`` for the message which was just sent. Args: item_id: Takes the ID of the message as an argument. """ - dummy, data = self.mail.fetch(item_id, '(RFC822)') + dummy, data = self.mail.fetch(item_id, "(RFC822)") for response_part in data: if not isinstance(response_part, tuple): continue - original_email = email.message_from_bytes(response_part[1]) # gets the raw content - sender = str(make_header(decode_header((original_email['From']).split(' <')[0]))) - sub = str(make_header(decode_header(original_email['Subject']))) - to = str(make_header(decode_header(original_email['To']))) - if to == self.to and sub == self.subject and sender == self.username and \ - original_email.__dict__.get('_payload', '').strip() == self.body.strip(): - self.mail.store(item_id.decode('UTF-8'), '+FLAGS', '\\Deleted') + original_email = email.message_from_bytes( + response_part[1] + ) # gets the raw content + sender = str( + make_header(decode_header((original_email["From"]).split(" <")[0])) + ) + sub = str(make_header(decode_header(original_email["Subject"]))) + to = str(make_header(decode_header(original_email["To"]))) + if ( + to == self.to + and sub == self.subject + and sender == self.username + and original_email.__dict__.get("_payload", "").strip() + == self.body.strip() + ): + self.mail.store(item_id.decode("UTF-8"), "+FLAGS", "\\Deleted") self.mail.expunge() - return dict(msg_id=original_email['Message-ID'], - msg_context=" ".join(original_email['Received'].split())) + return dict( + msg_id=original_email["Message-ID"], + msg_context=" ".join(original_email["Received"].split()), + ) def delete_sent(self) -> Union[Dict[str, str], None]: """Deletes the email from GMAIL's sent items right after sending the message. @@ -76,12 +86,18 @@ def delete_sent(self) -> Union[Dict[str, str], None]: """ if self.mail is None: return - return_code, messages = self.mail.search(None, 'ALL') # Includes SEEN and UNSEEN, although sent is always SEEN - if return_code != 'OK': + return_code, messages = self.mail.search( + None, "ALL" + ) # Includes SEEN and UNSEEN, although sent is always SEEN + if return_code != "OK": return with ThreadPoolExecutor(max_workers=1) as executor: - for deleted in executor.map(self.thread_executor, sorted(messages[0].split(), reverse=True)): - if deleted: # Indicates the message sent has been deleted, so no need to loop through entire sent items + for deleted in executor.map( + self.thread_executor, sorted(messages[0].split(), reverse=True) + ): + if ( + deleted + ): # Indicates the message sent has been deleted, so no need to loop through entire sent items executor.shutdown(cancel_futures=True) self.mail.close() self.mail.logout() diff --git a/gmailconnector/validator/address.py b/gmailconnector/validator/address.py index dc24ad4..e12d589 100644 --- a/gmailconnector/validator/address.py +++ b/gmailconnector/validator/address.py @@ -14,8 +14,7 @@ class EmailAddress: """ - def __init__(self, - address: str): + def __init__(self, address: str): """Converts address into IDNA (Internationalized Domain Name) format. Args: @@ -23,13 +22,13 @@ def __init__(self, """ self._address = address try: - self._user, self._domain = self._address.rsplit('@', 1) + self._user, self._domain = self._address.rsplit("@", 1) except ValueError: raise AddressFormatError if not self._user: raise AddressFormatError("Empty user") try: - self._domain = idna_encode(self._domain).decode('ascii') + self._domain = idna_encode(self._domain).decode("ascii") except IDNAError as error: raise AddressFormatError(error) @@ -46,4 +45,4 @@ def domain(self) -> Union[str, ipaddress.IPv4Address, ipaddress.IPv6Address]: @property def email(self) -> str: """Returns the email address.""" - return '@'.join((self.user, self.domain)) + return "@".join((self.user, self.domain)) diff --git a/gmailconnector/validator/domain.py b/gmailconnector/validator/domain.py index 31a6767..4f7e2c1 100644 --- a/gmailconnector/validator/domain.py +++ b/gmailconnector/validator/domain.py @@ -9,11 +9,12 @@ from .exceptions import InvalidDomain, NotMailServer, UnresponsiveMailServer -default_logger = logging.getLogger('validator') +default_logger = logging.getLogger("validator") -def get_mx_records(domain: str, - logger: logging.Logger = default_logger) -> Generator[Union[str, IPv4Address, IPv6Address]]: +def get_mx_records( + domain: str, logger: logging.Logger = default_logger +) -> Generator[Union[str, IPv4Address, IPv6Address]]: """Get MX (Mail Exchange server) records for the given domain. Args: @@ -25,7 +26,7 @@ def get_mx_records(domain: str, IP addresses of the mail exchange servers from authoritative/non-authoritative answer section. """ try: - resolved: Iterable[Answer] = resolve(domain, 'MX') + resolved: Iterable[Answer] = resolve(domain, "MX") except NXDOMAIN as error: raise InvalidDomain(error) except NoAnswer as error: @@ -34,8 +35,10 @@ def get_mx_records(domain: str, raise NotMailServer(f"Domain {domain!r} is not a mail server.") for record in resolved: record: MX = record - if record.exchange.to_text().strip() == '.': - raise UnresponsiveMailServer(f"Domain {domain!r} appears to be valid, but failed to resolve IP addresses.") + if record.exchange.to_text().strip() == ".": + raise UnresponsiveMailServer( + f"Domain {domain!r} appears to be valid, but failed to resolve IP addresses." + ) try: ip = socket.gethostbyname(record.exchange.to_text()) except socket.error as error: @@ -43,6 +46,8 @@ def get_mx_records(domain: str, raise UnresponsiveMailServer(error) except UnicodeError as error: logger.error(error) - raise UnresponsiveMailServer(f"Domain {domain!r} appears to be valid, but failed to resolve IP addresses.") + raise UnresponsiveMailServer( + f"Domain {domain!r} appears to be valid, but failed to resolve IP addresses." + ) logger.info(f"{record.preference}\t{record.exchange}\t{ip}") yield ip diff --git a/gmailconnector/validator/validate_email.py b/gmailconnector/validator/validate_email.py index 55b7546..ef6363b 100644 --- a/gmailconnector/validator/validate_email.py +++ b/gmailconnector/validator/validate_email.py @@ -10,22 +10,24 @@ from .exceptions import (AddressFormatError, InvalidDomain, NotMailServer, UnresponsiveMailServer) -formatter = logging.Formatter(fmt='%(levelname)s\t %(message)s') +formatter = logging.Formatter(fmt="%(levelname)s\t %(message)s") handler = logging.StreamHandler() handler.setFormatter(fmt=formatter) -default_logger = logging.getLogger('validator') +default_logger = logging.getLogger("validator") default_logger.addHandler(hdlr=handler) default_logger.setLevel(level=logging.DEBUG) -def validate_email(email_address: str, - timeout: Union[int, float] = 5, - sender: str = None, - debug: bool = False, - smtp_check: bool = True, - logger: logging.Logger = default_logger) -> Response: +def validate_email( + email_address: str, + timeout: Union[int, float] = 5, + sender: str = None, + debug: bool = False, + smtp_check: bool = True, + logger: logging.Logger = default_logger, +) -> Response: """Validates email address deliver-ability using SMTP. Args: @@ -51,40 +53,48 @@ def validate_email(email_address: str, try: address = EmailAddress(address=email_address) except AddressFormatError as error: - return Response(dictionary={ - 'ok': False, - 'status': 422, - 'body': f"Invalid address: {email_address!r}. {error}" if str(error).strip() else - f"Invalid address: {email_address!r}." - }) + return Response( + dictionary={ + "ok": False, + "status": 422, + "body": ( + f"Invalid address: {email_address!r}. {error}" + if str(error).strip() + else f"Invalid address: {email_address!r}." + ), + } + ) if not smtp_check: try: list(get_mx_records(domain=address.domain)) except (InvalidDomain, NotMailServer, UnresponsiveMailServer) as error: logger.error(error) - return Response(dictionary={ - 'ok': False, - 'status': 422, - 'body': error.__str__() - }) - return Response(dictionary={ - 'ok': True, - 'status': 200, - 'body': f'{address.email!r} is valid' - }) + return Response( + dictionary={"ok": False, "status": 422, "body": error.__str__()} + ) + return Response( + dictionary={ + "ok": True, + "status": 200, + "body": f"{address.email!r} is valid", + } + ) try: server = smtplib.SMTP(timeout=timeout) except (smtplib.SMTPException, socket.error) as error: - return Response(dictionary={ - 'ok': False, - 'status': 408, - 'body': error.__str__() or "failed to create a connection with gmail's SMTP server" - }) + return Response( + dictionary={ + "ok": False, + "status": 408, + "body": error.__str__() + or "failed to create a connection with gmail's SMTP server", + } + ) try: for record in get_mx_records(domain=address.domain): - logger.info(f'Trying {record}...') + logger.info(f"Trying {record}...") try: server.connect(host=record) except socket.error as error: @@ -93,33 +103,35 @@ def validate_email(email_address: str, server.ehlo_or_helo_if_needed() server.mail(sender=sender or address.email) code, msg = server.rcpt(recip=address.email) - msg = re.sub(r"\d+.\d+.\d+", '', msg.decode(encoding='utf-8')).strip() - msg = ' '.join(msg.splitlines()).replace(' ', ' ').strip() if msg else "Unknown error" + msg = re.sub(r"\d+.\d+.\d+", "", msg.decode(encoding="utf-8")).strip() + msg = ( + " ".join(msg.splitlines()).replace(" ", " ").strip() + if msg + else "Unknown error" + ) if code == 550: # Definitely invalid email address - logger.info(f'Invalid email address: {address.email}') - return Response(dictionary={ - 'ok': False, - 'status': 550, - 'body': msg - }) + logger.info(f"Invalid email address: {address.email}") + return Response(dictionary={"ok": False, "status": 550, "body": msg}) if code < 400: # Valid email address - logger.info(f'Valid email address: {address.email}') - return Response(dictionary={ - 'ok': True, - 'status': 200, - 'body': f"'{msg}' at MX:{record}" - }) - logger.info(f'Temporary error: {code} - {msg}') - logger.error('Received multiple temporary errors. Could not finish validation.') - return Response(dictionary={ - 'ok': None, - 'status': 207, - 'body': 'Received multiple temporary errors. Could not finish validation.' - }) + logger.info(f"Valid email address: {address.email}") + return Response( + dictionary={ + "ok": True, + "status": 200, + "body": f"'{msg}' at MX:{record}", + } + ) + logger.info(f"Temporary error: {code} - {msg}") + logger.error("Received multiple temporary errors. Could not finish validation.") + return Response( + dictionary={ + "ok": None, + "status": 207, + "body": "Received multiple temporary errors. Could not finish validation.", + } + ) except (InvalidDomain, NotMailServer, UnresponsiveMailServer) as error: logger.error(error) - return Response(dictionary={ - 'ok': False, - 'status': 422, - 'body': error.__str__() - }) + return Response( + dictionary={"ok": False, "status": 422, "body": error.__str__()} + ) diff --git a/release_notes.rst b/release_notes.rst index 3035135..d982430 100644 --- a/release_notes.rst +++ b/release_notes.rst @@ -1,6 +1,11 @@ Release Notes ============= +v1.0.1 (05/26/2024) +------------------- +- Includes a retry logic for occasional errors while sending emails +- Improved linting and updates to docs and dependencies + v1.0 (10/26/2023) ----------------- - Uses ``pydantic`` for input validations diff --git a/test_runner.py b/test_runner.py index 5584dd8..0e60b2f 100644 --- a/test_runner.py +++ b/test_runner.py @@ -6,16 +6,24 @@ logger = logging.getLogger(__name__) handler = logging.StreamHandler() -handler.setFormatter(logging.Formatter('%(asctime)s - [%(module)s:%(lineno)d] - %(funcName)s - %(message)s')) +handler.setFormatter( + logging.Formatter( + "%(asctime)s - [%(module)s:%(lineno)d] - %(funcName)s - %(message)s" + ) +) logger.addHandler(handler) -if os.getenv('debug'): +if os.getenv("debug"): debug = True logger.setLevel(logging.DEBUG) else: debug = False logger.setLevel(logging.INFO) -logger.info("RUNNING TESTS on version: %s with logger level: %s", gc.version, logging.getLevelName(logger.level)) +logger.info( + "RUNNING TESTS on version: %s with logger level: %s", + gc.version, + logging.getLevelName(logger.level), +) def test_run_read_email(): @@ -26,9 +34,13 @@ def test_run_read_email(): filter2 = gc.Condition.subject(subject="Security Alert") filter3 = gc.Condition.text(text=reader.env.gmail_user) filter4 = gc.Category.not_deleted - response = reader.instantiate(filters=(filter1, filter2, filter3, filter4)) # Apply multiple filters + response = reader.instantiate( + filters=(filter1, filter2, filter3, filter4) + ) # Apply multiple filters assert response.status <= 299, response.body - for each_mail in reader.read_mail(messages=response.body, humanize_datetime=False): # False to get datetime object + for each_mail in reader.read_mail( + messages=response.body, humanize_datetime=False + ): # False to get datetime object logger.debug(each_mail.date_time.date()) logger.debug("[%s] %s" % (each_mail.sender_email, each_mail.sender)) logger.debug("[%s] - %s" % (each_mail.subject, each_mail.body)) @@ -41,8 +53,12 @@ def test_run_send_email_tls(): sender = gc.SendEmail(encryption=gc.Encryption.TLS) auth_status = sender.authenticate assert auth_status.ok, auth_status.body - response = sender.send_email(recipient=sender.env.recipient, sender="GmailConnector Tester", - subject="GmailConnector Test Run - TLS - " + datetime.datetime.now().strftime('%c')) + response = sender.send_email( + recipient=sender.env.recipient, + sender="GmailConnector Tester", + subject="GmailConnector Test Run - TLS - " + + datetime.datetime.now().strftime("%c"), + ) assert response.ok, response.body logger.info("Test successful on send email using TLS") @@ -53,8 +69,12 @@ def test_run_send_email_ssl(): sender = gc.SendEmail(encryption=gc.Encryption.SSL) auth_status = sender.authenticate assert auth_status.ok, auth_status.body - response = sender.send_email(recipient=sender.env.recipient, sender="GmailConnector Tester", - subject="GmailConnector Test Run - SSL - " + datetime.datetime.now().strftime('%c')) + response = sender.send_email( + recipient=sender.env.recipient, + sender="GmailConnector Tester", + subject="GmailConnector Test Run - SSL - " + + datetime.datetime.now().strftime("%c"), + ) assert response.ok, response.body logger.info("Test successful on send email using SSL") @@ -65,8 +85,12 @@ def test_run_send_sms_tls(): sender = gc.SendSMS(encryption=gc.Encryption.TLS) auth_status = sender.authenticate assert auth_status.ok, auth_status.body - response = sender.send_sms(subject="GmailConnector Test Run - TLS", delete_sent=True, - message=datetime.datetime.now().strftime('%c')) + response = sender.send_sms( + subject="GmailConnector Test Run - TLS", + delete_sent=True, + phone="1234567890", + message=datetime.datetime.now().strftime("%c"), + ) assert response.ok, response.body logger.info("Test successful on send sms using TLS") @@ -77,8 +101,12 @@ def test_run_send_sms_ssl(): sender = gc.SendSMS(encryption=gc.Encryption.SSL) auth_status = sender.authenticate assert auth_status.ok, auth_status.body - response = sender.send_sms(subject="GmailConnector Test Run - SSL", delete_sent=True, - message=datetime.datetime.now().strftime('%c')) + response = sender.send_sms( + subject="GmailConnector Test Run - SSL", + delete_sent=True, + phone="1234567890", + message=datetime.datetime.now().strftime("%c"), + ) assert response.ok, response.body logger.info("Test successful on send sms using SSL") @@ -86,7 +114,9 @@ def test_run_send_sms_ssl(): def test_run_validate_email_smtp_off(): """Test run on email validator with SMTP disabled.""" logger.info("Test initiated on email validator with SMTP disabled.") - response = gc.validate_email(gc.EgressConfig().gmail_user, smtp_check=False, debug=debug, logger=logger) + response = gc.validate_email( + gc.EgressConfig().gmail_user, smtp_check=False, debug=debug, logger=logger + ) assert response.ok, response.body logger.info("Test successful on validate email with SMTP enabled.") @@ -94,12 +124,14 @@ def test_run_validate_email_smtp_off(): def test_run_validate_email_smtp_on(): """Test run on email validator with SMTP enabled.""" logger.info("Test initiated on email validator with SMTP enabled.") - response = gc.validate_email(gc.IngressConfig().gmail_user, smtp_check=True, debug=debug, logger=logger) + response = gc.validate_email( + gc.IngressConfig().gmail_user, smtp_check=True, debug=debug, logger=logger + ) assert response.status <= 299, response.body logger.info("Test successful on validate email with SMTP disabled.") -if __name__ == '__main__': +if __name__ == "__main__": test_run_validate_email_smtp_off() test_run_validate_email_smtp_on() test_run_send_email_tls()