diff --git a/.gitignore b/.gitignore index 1d17dae..0c64d4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .venv +*/__pycache__/ diff --git a/Pipfile b/Pipfile index a6f8e9b..42a312c 100644 --- a/Pipfile +++ b/Pipfile @@ -9,6 +9,7 @@ mailtm = {path = "."} [packages] requests = "*" random-username = "*" +pyperclip = "*" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index d96bb62..c401994 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "815f78569f116d9fc7b3f2d0253735263b82aa494e4599b1620777375e13e150" + "sha256": "2146890d854e6c669256e23b455771b4c56a6c575c45319bcd913315100eec01" }, "pipfile-spec": 6, "requires": { @@ -37,6 +37,13 @@ ], "version": "==2.10" }, + "pyperclip": { + "hashes": [ + "sha256:b75b975160428d84608c26edba2dec146e7799566aea42c1fe1b32e72b6028f2" + ], + "index": "pypi", + "version": "==1.8.0" + }, "random-username": { "hashes": [ "sha256:2536feb63fecde7e01ede4a541aadb6f0b58794a7ab327ca5369d2a4b7664c06", diff --git a/README.md b/README.md index 0b2b808..df1e278 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ This is a python interface to the web api of [mail.tm](https://mail.tm). The api is documented [here](https://api.mail.tm/). +## Dependencies + +`xclip` or `xsel` for clipboard copying. + ## Installation #### With pip @@ -36,7 +40,7 @@ pipenv install --dev ## Usage -You can now import the library `pymailtm` and call the utility with the same name: +The utility can be called with: ```bash pymailtm @@ -56,13 +60,24 @@ If using git + Pipenv, and have [invoke](https://github.com/pyinvoke/invoke) ins inv run ``` -Exit the utility by pressing `Ctrl+c`. +By default the command recover the last used account, copy it to the clipboard and wait for a new message to arrive: when +it does, it's opened in the browser in a quick html view. + +Exit the waiting loop by pressing `Ctrl+c`. + +Calling the utility with the flag `-l` will print the account credentials, open in the browser the +[mail.tm](https://mail.tm) client and exit. + +The flag `-n` can be used to force the creation of a new account. ## Security warnings -This is conceived as an insecure, fast throwaway temp mail account generator. +This is conceived as an **insecure**, fast throwaway temp mail account generator. -Do not use it with sensitive data. +**DO NOT** use it with sensitive data. -Mails that arrive while the utility is running will be saved in clear text files in a temp folder (probably `/tmp/`) so +Mails that arrive while the utility is running will be saved in **plain text** files in the system temporary folder (probably `/tmp/`) so that they can be opened by the browser. + +The last used account's data and credentials will be saved in +**plain text** in `~/.pymailtm`. diff --git a/pymailtm/__init__.py b/pymailtm/__init__.py index eb697a8..ab149b1 100644 --- a/pymailtm/__init__.py +++ b/pymailtm/__init__.py @@ -1,13 +1,3 @@ -import signal -import sys from pymailtm.pymailtm import MailTm, Account, Message - -def init(): - def signal_handler(sig, frame): - print('\n\nClosing! Bye!') - sys.exit(0) - - signal.signal(signal.SIGINT, signal_handler) - - MailTm().monitor_new_account() +__version__ = '1.0' diff --git a/pymailtm/pymailtm.py b/pymailtm/pymailtm.py index 67d6280..bded663 100644 --- a/pymailtm/pymailtm.py +++ b/pymailtm/pymailtm.py @@ -1,5 +1,6 @@ import json import os +import pyperclip import random import requests import string @@ -7,55 +8,12 @@ from random_username.generate import generate_username from dataclasses import dataclass +from pathlib import Path from tempfile import NamedTemporaryFile from time import sleep from typing import Dict -class MailTm: - """A python wrapper for mail.tm web api, which is documented here: - https://api.mail.tm/""" - - api_address = "https://api.mail.tm" - - def _get_domains_list(self): - r = requests.get("{}/domains".format(self.api_address)) - response = r.json() - domains = list(map(lambda x: x["domain"], response["hydra:member"])) - return domains - - def get_account(self, password=None): - """Create and return a new account.""" - username = (generate_username(1)[0]).lower() - domain = random.choice(self._get_domains_list()) - address = "{}@{}".format(username, domain) - if not password: - password = self._generate_password(6) - response = self._make_account_request("accounts", address, password) - return Account(response["id"], response["address"], password) - - def _generate_password(self, length): - letters = string.ascii_letters + string.digits - return ''.join(random.choice(letters) for i in range(length)) - - @staticmethod - def _make_account_request(endpoint, address, password): - account = {"address": address, "password": password} - headers = { - "accept": "application/ld+json", - "Content-Type": "application/json" - } - r = requests.post("{}/{}".format(MailTm.api_address, endpoint), - data=json.dumps(account), headers=headers) - return r.json() - - def monitor_new_account(self): - """Create a new account and monitor it for new messages.""" - account = self.get_account() - print("New account created: {}".format(account.address)) - account.monitor_account() - - class Account: """Representing a temprary mailbox.""" @@ -67,10 +25,10 @@ def __init__(self, id, address, password): jwt = MailTm._make_account_request("authentication_token", self.address, self.password) self.auth_headers = { - "accept": "application/ld+json", - "Content-Type": "application/json", - "Authorization": "Bearer {}".format(jwt["token"]) - } + "accept": "application/ld+json", + "Content-Type": "application/json", + "Authorization": "Bearer {}".format(jwt["token"]) + } self.api_address = MailTm.api_address def get_messages(self, page=1): @@ -79,28 +37,31 @@ def get_messages(self, page=1): headers=self.auth_headers) messages = [] for message_data in r.json()["hydra:member"]: - message = Message( + # recover full message + r = requests.get( + f"{self.api_address}/messages/{message_data['id']}", headers=self.auth_headers) + text = r.json()["text"] + html = r.json()["html"] + # prepare the mssage object + messages.append(Message( message_data["id"], message_data["from"], message_data["to"], message_data["subject"], message_data["intro"], - message_data, - ) - r = requests.get(f"{self.api_address}/messages/{message.id_}", headers=self.auth_headers) - message.text = r.json()["text"] - message.html = r.json()["html"] - messages.append(message) + text, + html, + message_data)) return messages def delete_account(self): """Try to delete the account. Returns True if it succeeds.""" r = requests.delete("{}/accounts/{}".format(self.api_address, - self.id_), headers=self.auth_headers) + self.id_), headers=self.auth_headers) return r.status_code == 204 def monitor_account(self): - """Keep waiting for new messages and open then in the browser.""" + """Keep waiting for new messages and open them in the browser.""" while True: print("\nWaiting for new messages...") start = len(self.get_messages()) @@ -112,12 +73,14 @@ def monitor_account(self): @dataclass class Message: - """Simple data class that holds a message informations.""" + """Simple data class that holds a message information.""" id_: str from_: Dict to: Dict subject: str intro: str + text: str + html: str data: Dict def open_web(self): @@ -136,18 +99,130 @@ def open_web(self): f.write(message) f.flush() - os.fsync(f) file_name = f.name - # webbrowser must be silent! - saverr = os.dup(2) - os.close(2) - os.open(os.devnull, os.O_RDWR) + open_webbrowser("file://{}".format(file_name)) + # Wait a second before deleting the tempfile, so that the + # browser can load it safely + sleep(1) + os.remove(file_name) + + +def open_webbrowser(link: str) -> None: + """Open a url in the browser ignoring error messages.""" + saverr = os.dup(2) + os.close(2) + os.open(os.devnull, os.O_RDWR) + try: + webbrowser.open(link) + finally: + os.dup2(saverr, 2) + + +class CouldNotGetAccountException(Exception): + """Raised if a POST on /accounts or /authorization_token return a failed status code.""" + + +class InvalidDbAccountException(Exception): + """Raised if an account could not be recovered from the db file.""" + + +class MailTm: + """A python wrapper for mail.tm web api, which is documented here: + https://api.mail.tm/""" + + api_address = "https://api.mail.tm" + db_file = os.path.join(Path.home(), ".pymailtm") + + def _get_domains_list(self): + r = requests.get("{}/domains".format(self.api_address)) + response = r.json() + domains = list(map(lambda x: x["domain"], response["hydra:member"])) + return domains + + def get_account(self, password=None): + """Create and return a new account.""" + username = (generate_username(1)[0]).lower() + domain = random.choice(self._get_domains_list()) + address = "{}@{}".format(username, domain) + if not password: + password = self._generate_password(6) + response = self._make_account_request("accounts", address, password) + account = Account(response["id"], response["address"], password) + self._save_account(account) + return account + + def _generate_password(self, length): + letters = string.ascii_letters + string.digits + return ''.join(random.choice(letters) for i in range(length)) + + @staticmethod + def _make_account_request(endpoint, address, password): + account = {"address": address, "password": password} + headers = { + "accept": "application/ld+json", + "Content-Type": "application/json" + } + r = requests.post("{}/{}".format(MailTm.api_address, endpoint), + data=json.dumps(account), headers=headers) + if r.status_code not in [200, 201]: + raise CouldNotGetAccountException() + return r.json() + + def monitor_new_account(self, force_new=False): + """Create a new account and monitor it for new messages.""" + account = self._open_account(new=force_new) + account.monitor_account() + + def _save_account(self, account: Account): + """Save the account data for later use.""" + data = { + "id": account.id_, + "address": account.address, + "password": account.password + } + with open(self.db_file, "w+") as db: + json.dump(data, db) + + def _load_account(self): + """Return the last used account.""" + with open(self.db_file, "r") as db: + data = json.load(db) + # send a /me request to ensure the account is there + if "address" not in data or "password" not in data or "id" not in data: + # No valid db file was found, raise + raise InvalidDbAccountException() + else: + return Account(data["id"], data["address"], data["password"]) + + def _open_account(self, new=False): + """Recover a saved account data, check if it's still there and return that one; otherwise create a new one and + return it. + + :param new: bool - force the creation of a new account""" + def _new(): + account = self.get_account() + print("New account created and copied to clipboard: {}".format( + account.address)) + return account + if new: + account = _new() + else: try: - webbrowser.open("file://{}".format(file_name)) - finally: - os.dup2(saverr, 2) - # Wait a second before deleting the tempfile, so that the - # browser can load it safely - sleep(1) - os.remove(file_name) + account = self._load_account() + print("Account recovered and copied to clipboard: {}".format( + account.address)) + except Exception: + account = _new() + pyperclip.copy(account.address) + print("") + return account + + def browser_login(self, new=False): + """Print login credentials and open the login page in the browser.""" + account = self._open_account(new=new) + print("\nAccount credentials:") + print("\nEmail: {}".format(account.address)) + print("Password: {}\n".format(account.password)) + open_webbrowser("https://mail.tm/") + sleep(1) # Allow for the output of webbrowser to arrive diff --git a/run.py b/run.py deleted file mode 100644 index 2ddcd77..0000000 --- a/run.py +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python -from pymailtm import init - - -init() diff --git a/scripts/pymailtm b/scripts/pymailtm new file mode 100644 index 0000000..ad7dbe5 --- /dev/null +++ b/scripts/pymailtm @@ -0,0 +1,33 @@ +#!/usr/bin/env python +from argparse import ArgumentParser +import signal +import sys +from pymailtm import MailTm + + +def init(): + def signal_handler(sig, frame) -> None: + print('\n\nClosing! Bye!') + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + + parser = ArgumentParser( + description="A python interface to mail.tm web api. The temp mail address " + "will be copied to the clipboard and the utility will then " + "wait for a message to arrive. When it does, it will be " + "opened in a browser. Exit the loop with ctrl+c.") + parser.add_argument('-n', '--new-account', action='store_true', + help="whether to force the creation of a new account") + parser.add_argument('-l', '--login', action='store_true', + help="print the credentials and open the login page, then exit") + args = parser.parse_args() + + if args.login: + MailTm().browser_login(new=args.new_account) + else: + MailTm().monitor_new_account(force_new=args.new_account) + + +if __name__ == "__main__": + init() diff --git a/setup.py b/setup.py index 1b9040f..0eeec8f 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,19 @@ +import io +import re from setuptools import setup + +with io.open('pymailtm/__init__.py', 'rt', encoding='utf8') as f: + version = re.search(r'__version__ = \'(.*?)\'', f.read()).group(1) + setup( name="pymailtm", - version="0.1", + version=version, packages=["pymailtm"], install_requires=[ "random-username>=1.0.2", - "requests>=2.24.0" + "requests>=2.24.0", + "pyperclip>=1.8.0" ], url="https://github.com/CarloDePieri/pymailtm", license='GPLv3', @@ -14,9 +21,5 @@ author_email='depieri.carlo@gmail.com', description='A python wrapper around mail.tm web api.', include_package_data=True, - entry_points={ - 'console_scripts': [ - 'pymailtm = pymailtm:init' - ] - }, + scripts=["scripts/pymailtm"], ) diff --git a/tasks.py b/tasks.py index b126cec..8956e81 100644 --- a/tasks.py +++ b/tasks.py @@ -2,5 +2,10 @@ @task -def run(c): - c.run("pipenv run python run.py", pty=True) +def run(c, n=False, l=False): + if l: + c.run("pipenv run python scripts/pymailtm -l", pty=True) + elif n: + c.run("pipenv run python scripts/pymailtm -n", pty=True) + else: + c.run("pipenv run python scripts/pymailtm", pty=True)