3
\$\begingroup\$

This is a basic CLI email client in Python. It can send plain text emails and view inbox. settings.json contains a list of SMTP and IMAP servers used for different providers. This file can be edited.

client.py

import argparse
import email
import getpass
import imaplib
import json
import os
import re
import smtplib
import ssl
import webbrowser


def clean(text):
    # Clean text for creating a folder.
    return "".join(c if c.isalnum() else "_" for c in text)


def get_email():
    user_email = input("Email: ")
    # Validate the email entered.
    while not re.match(r"[^@]+@[^@]+\.[^@]+", user_email):
        print("Error: Email provided is invalid.")
        user_email = input("Email: ")
    return user_email

def get_credentials():
    user_email = get_email()
    # Mask the password entered.
    password = getpass.getpass(prompt="Password: ")
    return user_email, password


def new_email():
    email, password = get_credentials()
    smtp_server = ""
    with open("settings.json", "r") as settings_file:
        settings = json.load(settings_file)
        try:
            smtp_server = settings[email[email.find("@") + 1 : email.rfind(".")]]["smtp_server"]
        except KeyError:
            print("Error: Email provider not supported.")
            return
    recipient = get_email()
    subject = input("Subject: ")
    content = input("Message: ")
    message = "Subject:" + subject + "\n\n" + content
    # Set up SMTP server.
    port = 587 
    context = ssl.create_default_context()
    # Try to log in and send the email.
    try:
        server = smtplib.SMTP(smtp_server,port)
        server.ehlo()
        server.starttls(context=context)
        server.ehlo()
        server.login(email, password)
        server.sendmail(email, recipient, message)
    except Exception as e:
        # Print any error messages to stdout.
        print(e)
    finally:
        server.quit()


def view_inbox():
    # Repeat prompting for credentials until correct ones are provided.
    logged_in = False
    while not logged_in:
        user_email, password = get_credentials()

        # Try to load the IMAP server name from settings.json file.
        imap_server_name = ""
        with open("settings.json", "r") as settings_file:
            settings = json.load(settings_file)
            try:
                imap_server_name = settings[user_email[user_email.find("@") + 1 : user_email.rfind(".")]]["imap_server"]
            except KeyError:
                print("Error: Email provider not supported.")
                continue
        
        # Initialise the IMAP server.
        imap_server = imaplib.IMAP4_SSL(imap_server_name)
        try:
            imap_server.login(user_email, password)
        except:
            print("Error: Incorrect credentials.")
        else:
            logged_in = True

    status, messages = imap_server.select("INBOX")
    while True:
        try:
            emails_to_load = int(input("Select a number of emails to load: "))
        except ValueError:
            print("Error: Invalid value.")
            continue
        else:
            break
    messages = int(messages[0])
    for i in range(messages, messages - emails_to_load, -1):
        data = imap_server.fetch(str(i), "(RFC822)" )
        for response_part in data:
            arr = response_part[0]
            if isinstance(arr, tuple):
                msg = email.message_from_string(str(arr[1],"utf-8"))
                email_subject = msg["subject"] if msg["subject"] else ""
                email_from = msg["from"]
                print("From : " + email_from + "\n")
                print("Subject : " + email_subject + "\n")
                # If the message is multipart.
                if msg.is_multipart():
                    # Iterate over email parts.
                    for part in msg.walk():
                        # Extract content type of the email.
                        content_type = part.get_content_type()
                        content_disposition = str(part.get("Content-Disposition"))
                        try:
                            # Get the email body.
                            body = part.get_payload(decode=True).decode()
                        except:
                            pass
                        if content_type == "text/plain" and "attachment" not in content_disposition:
                            # Print text/plain emails and skip attachments.
                            print(body)
                        elif "attachment" in content_disposition:
                            # Download attachment.
                            filename = part.get_filename()
                            if filename:
                                folder_name = clean(email_subject)
                                if not os.path.isdir(folder_name):
                                    # Make a folder for this email (named after the subject).
                                    os.mkdir(folder_name)
                                filepath = os.path.join(folder_name, filename)
                                # Download attachment and save it.
                                open(filepath, "wb").write(part.get_payload(decode=True))
                else:
                    # Extract content type of email.
                    content_type = msg.get_content_type()
                    # Get the email body.
                    body = msg.get_payload(decode=True).decode()
                    if content_type == "text/plain":
                        # Print only text email parts.
                        print(body)
                if content_type == "text/html":
                    answer = input("The following email contains HTML. Would you like to open it (y/n)?: ")
                    while answer != "y" and answer != "n":
                        print("Error: Invalid answer.")
                        answer = input("The following email contains HTML. Would you like to download and open it (y/n)?: ")
                    if answer == "y":
                        # If it is HTML, create a new HTML file and open it in browser if the user wishes to do so.
                        folder_name = clean(email_subject)
                        if not os.path.isdir(folder_name):
                            # Make a folder for this email (named after the subject).
                            os.mkdir(folder_name)
                        filename = "index.html"
                        filepath = os.path.join(folder_name, filename)
                        # Write the file.
                        open(filepath, "w").write(body)
                        # Open in the default browser.
                        webbrowser.open(filepath)
                print("=" * 100)

    imap_server.close()
    imap_server.logout()


def add_server():
    # Add a server to the settings.
    type = input("Type of server (SMTP / IMAP): ")
    while type.casefold() != "smtp" and type.casefold() != "imap":
        print("Error: Type must be SMTP or IMAP.")
        type = input("Type of server (SMTP / IMAP): ")

    with open("settings.json", "r+") as settings_file:
        name = input("Email provider: ")
        settings = json.load(settings_file) 
        if name in settings and type.casefold() + "_server" in settings[name]:
            print("Error: This provider already exists. You can try editing it.")
        else:
            server = input("Email server: ")
            if name in settings:
                settings[name][type.casefold() + "_server"] = server
                settings_file.seek(0)
                json.dump(settings, settings_file, indent=4)
            else:
                settings[name] = {
                    type.casefold() + "_server": server
                }
                settings_file.seek(0)
                json.dump(settings, settings_file, indent=4)


def edit_server():
    # Edit server in the settings.
    type = input("Type of server (SMTP / IMAP): ")
    while type.casefold() != "smtp" and type.casefold() != "imap":
        print("Error: Type must be SMTP or IMAP.")
        type = input("Type of server (SMTP / IMAP): ")

    with open("settings.json", "r+") as settings_file:
        name = input("Email provider: ")
        while not name:
            print("Error: Email provider can\'t be empty.")
            name = input("Email provider: ")
        settings = json.load(settings_file)
        if name not in settings or type.casefold() + "_server" not in settings[name]:
            print("Error: This provider doesn\'t exist. You can try adding it.")
        else:
            server = input("Email server: ")
            while not server:
                print("Error: Email server can\'t be empty. You can try removing it.")
                server = input("Email server: ")
            settings[name][type.casefold() + "_server"] = server
            settings_file.seek(0)
            json.dump(settings, settings_file, indent=4)


def remove_server():
    with open("settings.json", "r") as settings_file:
        name = input("Email provider: ")
        while not name:
            print("Error: Email provider can\'t be empty.")
            name = input("Email provider: ")
        settings = json.load(settings_file)
        while name not in settings:
            print("Error: This provider doesn\'t exist.")
            name = input("Email provider: ")
        else:
            answer = input("Are you sure (y/n)?: ")
            while answer != "y" and answer != "n":
                print("Error: Invalid answer.")
                answer = input("Are you sure (y/n)?: ")
            if answer == "y":
                del settings[name]
                with open("settings.json", "w") as settings_file:
                    json.dump(settings, settings_file, indent=4)


arg_parser = argparse.ArgumentParser()
arg_parser.add_argument("action", help="view/send/add/edit/remove")
args = arg_parser.parse_args()
# Dictionary that matches the value of action argument to a function that is supposed to be called.
try:
    action_to_function = {"view": view_inbox, "send": new_email, "add": add_server, "edit": edit_server, "remove": remove_server}
    action_to_function[args.action]()
except KeyError:
    arg_parser.print_help()

settings.json

{
    "outlook": {
        "smtp_server": "smtp.office365.com",
        "imap_server": "imap-mail.outlook.com"
    },
    "gmail": {
        "smtp_server": "smtp.gmail.com",
        "imap_server": "imap.gmail.com"
    }
}
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

Regex substitution

Your clean would be much simpler if you used the re module to substitute all non-alphanumeric characters, rather than iterating through the characters yourself, having a ternary and then having to re-join to a string.

Email validation

This regex:

[^@]+@[^@]+\.[^@]+

is, unfortunately, woefully insufficient in guaranteeing a valid email address. A trivial false positive is

'            @            .          '

There are many answers on how to be a little more careful, but this is a "hard problem", so your best bet is to call into a library like py3-validate-email, which can actually issue network calls to validate email addresses.

File scope

with open("settings.json", "r") as settings_file:
    settings = json.load(settings_file)
    try:
        smtp_server = settings[email[email.find("@") + 1 : email.rfind(".")]]["smtp_server"]
    except KeyError:
        print("Error: Email provider not supported.")
        return

is done with the file as soon as you have settings, so the try and onward should be de-indented.

Context management

It's good that you have a finally guaranteeing server.quit, but it would be better to put the server in a with, since SMTP is a context manager.

Your IMAP calls

imap_server.close()
imap_server.logout()

are missing similar protection.

Method complexity

view_inbox needs to be broken up into subroutines. It's way too long and complex right now.

Built-in shadowing

type = input("Type of server (SMTP / IMAP): ")

is a bad idea. type is a critical built-in that you've now made inaccessible in the rest of add_server.

Escaping

        print("Error: Email provider can\'t be empty.")

doesn't need a backslash since you have double quotes.

Main guard

From arg_parser onward, that needs to live in a main() method protected by a __name__ guard.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.