├── .gitignore ├── .pymail.sample.json ├── install.sh ├── README.md └── pymail.py /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | -------------------------------------------------------------------------------- /.pymail.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "sender": { 3 | "auth": { 4 | "user": "user@gmail.com", 5 | "pass": "password" 6 | } 7 | }, 8 | "addressBook": [ 9 | { "name": "Person1", "email": "person1@email.com" }, 10 | { "name": "Person2", "email": "person2@email.com" } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | SCRIPTPATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/pymail.py" 2 | CONFIGPATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/.pymail.sample.json" 3 | echo "creating symlink to $SCRIPTPATH" 4 | ln -s $SCRIPTPATH /usr/local/bin/pymail 5 | 6 | echo "creating necessary folder structure" 7 | mkdir -p ~/pymail/archive 8 | mkdir -p ~/pymail/outbox/sent 9 | 10 | echo "creating base config file at $CONFIGPATH" 11 | cp $CONFIGPATH ~/.pymail 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pymail 2 | 3 | *Send and receive email from the command line.* 4 | 5 | *Note: Gmail is the currently supported email provider* 6 | 7 | ## Setup 8 | 9 | Run `install.sh` in the root repository directory 10 | 11 | ``` 12 | $ ./install.sh 13 | ``` 14 | 15 | ## Configuration 16 | 17 | **Location**: `~/.pymail` 18 | 19 | ```json 20 | { 21 | "sender": { 22 | "auth": { 23 | "user": "user@gmail.com", 24 | "pass": "password" 25 | } 26 | }, 27 | "addressBook": [ 28 | { "name": "Person1", "email": "person1@email.com" }, 29 | { "name": "Person2", "email": "person2@email.com" } 30 | ] 31 | } 32 | ``` 33 | 34 | ## Significant directories 35 | 36 | - **Inbox**: `~/pymail/` 37 | - **Inbox archive**: `~/pymail/archive/` 38 | - **Outbox**: `~/pymail/outbox/` 39 | - **Outbox sent**: `~/pymail/outbox/sent/` 40 | 41 | ## Usage 42 | 43 | ### List addressbook contents 44 | 45 | ``` 46 | $ pymail addressbook 47 | ``` 48 | 49 | ### Send and receive email 50 | 51 | ``` 52 | $ pymail 53 | ``` 54 | -------------------------------------------------------------------------------- /pymail.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from datetime import datetime 3 | from email import parser 4 | import json 5 | import os 6 | import poplib 7 | import re 8 | import smtplib 9 | import sys 10 | 11 | """ 12 | Email class to handle the data and operations of emails 13 | """ 14 | class Email: 15 | def __init__(self, sender, to, subject, body, date, email_filename=None): 16 | self.sender = sender 17 | self.to = to 18 | self.subject = subject 19 | self.body = body 20 | self.date = date 21 | self.email_filename = email_filename 22 | 23 | def __str__(self): 24 | return '{}\n{}\n{}\n{}'.format(self.sender, self.subject, self.date, self.body) 25 | 26 | def recipient_is_email_address(self): 27 | return re.search(r'.*@.*\..*', self.to) is not None 28 | 29 | def send(self): 30 | config = Config() 31 | if not self.recipient_is_email_address(): 32 | found_address = False 33 | for address in config.addressbook: 34 | if self.to.lower() == address.name.lower(): 35 | self.to = address.address 36 | found_address = True 37 | if not found_address: 38 | raise ValueError('unable to resolve "{}" in addressbook'.format(self.to)) 39 | server = smtplib.SMTP('smtp.gmail.com', 587) 40 | server.ehlo() 41 | server.starttls() 42 | server.login(config.username, config.password) 43 | message = 'Subject: %s\n\n%s' % (self.subject, self.body) 44 | server.sendmail(config.username, self.to, message) 45 | server.close() 46 | 47 | def archive(self): 48 | config = Config() 49 | if self.sender == config.username: 50 | # in this case this is an outgoing email 51 | FileSystem.archive_sent_email(self) 52 | elif self.to == config.username: 53 | FileSystem.archive_inbox_email(self) 54 | else: 55 | raise ValueError('unable to determine if sent or received email') 56 | 57 | def save_in_inbox(self): 58 | FileSystem.cache_email_in_inbox(self) 59 | 60 | def generate_filename(self): 61 | return '{}_{}.email'.format( 62 | datetime.now().strftime(r'%Y%m%d%H%M%S%f'), 63 | self.sender.replace(' ', '').replace('<', '_').replace('>', '_')) 64 | 65 | @staticmethod 66 | def receive(): 67 | config = Config() 68 | connection = poplib.POP3_SSL('pop.gmail.com') 69 | connection.user(config.username) 70 | connection.pass_(config.password) 71 | messages = [connection.retr(i) for i in range(1, len(connection.list()[1]) + 1)] 72 | messages = [b"\n".join(temp_message[1]).decode('utf-8') for temp_message in messages] 73 | messages = [parser.Parser().parsestr(temp_message) for temp_message in messages] 74 | for message in messages: 75 | if type(message._payload) is str: 76 | message_body = message._payload 77 | else: 78 | message_body = message._payload[0]._payload 79 | yield Email(message['from'], message['to'], message['subject'], message_body, message['date']) 80 | connection.quit() 81 | 82 | @staticmethod 83 | def parse(sender, raw_email): 84 | temp_raw_email = raw_email.splitlines() 85 | recipient = temp_raw_email.pop(0) 86 | subject = temp_raw_email.pop(0) 87 | body = '\n'.join(temp_raw_email) 88 | return Email(sender, recipient, subject, body) 89 | 90 | """ 91 | Address class to store a single address 92 | """ 93 | class Address: 94 | def __init__(self, name, address): 95 | self.name = name 96 | self.address = address 97 | 98 | def __str__(self): 99 | return '{} :: {}'.format(self.name, self.address) 100 | """ 101 | Config class to provide easy-to-consume configuration options 102 | """ 103 | class Config: 104 | def __init__(self): 105 | self.file_system = FileSystem() 106 | self.addressbook = [] 107 | self.__parse(self.file_system.CONFIG_FILENAME) 108 | 109 | def __parse(self, config_filename): 110 | with open(config_filename, 'r') as config_file: 111 | data = json.load(config_file) 112 | self.username = data['sender']['auth']['user'] 113 | self.password = data['sender']['auth']['pass'] 114 | for address in data['addressBook']: 115 | self.addressbook.append(Address(address['name'], address['email'])) 116 | 117 | """ 118 | FileSystem object to provide file system operations like 119 | caching and retrieving email files 120 | """ 121 | class FileSystem: 122 | CONFIG_FILENAME = os.path.join(os.path.expanduser('~'), '.pymail') 123 | INBOX_DIRECTORY = os.path.join(os.path.expanduser('~'), 'pymail') 124 | INBOX_ARCHIVE_DIRECTORY = os.path.join(os.path.expanduser('~'), 'pymail', 'archive') 125 | OUTBOX_DIRECTORY = os.path.join(os.path.expanduser('~'), 'pymail', 'outbox') 126 | SENT_DIRECTORY = os.path.join(os.path.expanduser('~'), 'pymail', 'outbox', 'sent') 127 | 128 | @staticmethod 129 | def __outbox_filenames(): 130 | for email_file in os.listdir(FileSystem.OUTBOX_DIRECTORY): 131 | if (os.path.isfile(os.path.join(FileSystem.OUTBOX_DIRECTORY, email_file)) and 132 | re.search(r'.*\.email$', email_file) is not None): 133 | yield os.path.join(FileSystem.OUTBOX_DIRECTORY, email_file) 134 | 135 | @staticmethod 136 | def outbox_emails(): 137 | config = Config() 138 | for email_file in FileSystem.__outbox_filenames(): 139 | with open(email_file, 'r') as ef: 140 | email = Email.parse(config.username, ef.read()) 141 | email.email_filename = email_file 142 | yield email 143 | 144 | @staticmethod 145 | def archive_inbox_email(email): 146 | if not email.email_filename: 147 | raise ValueError('no filename included in Email object') 148 | else: 149 | filename_without_path = os.path.basename(email.email_filename) 150 | new_filename = os.path.join(FileSystem.INBOX_ARCHIVE_DIRECTORY, filename_without_path) 151 | os.rename(email.email_filename, new_filename) 152 | 153 | @staticmethod 154 | def archive_sent_email(email): 155 | if not email.email_filename: 156 | raise ValueError('no filename included in Email object') 157 | else: 158 | filename_without_path = os.path.basename(email.email_filename) 159 | filename_without_path = '{}_{}'.format(datetime.now().strftime(r'%Y%m%d%H%M%S%f'), filename_without_path) 160 | new_filename = os.path.join(FileSystem.SENT_DIRECTORY, filename_without_path) 161 | os.rename(email.email_filename, new_filename) 162 | 163 | @staticmethod 164 | def cache_email_in_inbox(email): 165 | with open(os.path.join(FileSystem.INBOX_DIRECTORY, email.generate_filename()), 'w') as email_file: 166 | email_file.write(str(email)) 167 | 168 | """ 169 | main code execution 170 | """ 171 | def main(argv): 172 | config = Config() 173 | 174 | if len(argv) > 1 and argv[1].lower() == 'addressbook': 175 | for address in config.addressbook: 176 | print(str(address)) 177 | else: 178 | # send all emails that are pending 179 | counter = 0 180 | try: 181 | for email in FileSystem.outbox_emails(): 182 | email.send() 183 | counter += 1 184 | email.archive() 185 | except Exception as e: 186 | print(e) 187 | 188 | print('sent {} email(s)'.format(counter)) 189 | 190 | #receive new emails 191 | counter = 0 192 | try: 193 | for email in Email.receive(): 194 | email.save_in_inbox() 195 | counter += 1 196 | except Exception as e: 197 | print(e) 198 | 199 | print('received {} email(s)'.format(counter)) 200 | 201 | main(sys.argv) 202 | --------------------------------------------------------------------------------