PK!yVPPmaildown/__init__.pyfrom maildown.application import application def run(): application.run() PK!maildown/application.pyfrom cleo.application import Application from maildown import commands application = Application() application.add(commands.InitCommand()) application.add(commands.VerifyCommand()) application.add(commands.SendCommand()) PK!#^ maildown/commands.pyfrom cleo.commands import Command from maildown import utilities class InitCommand(Command): """ Configures Maildown for use init {access-key? : Your AWS Access Key ID} {secret-key? : Your AWS Secret key} {region? : AWS region to use (defaults to "us-east-1")} {aws-config-file? : Path to your AWS config file (defaults to ~/.aws/credentials} """ def handle(self): kwargs = dict() access_key = self.argument('access-key') secret_key = self.argument('secret-key') region = self.argument('region') aws_config_file = self.argument('aws-config-file') if access_key: kwargs['access_key'] = access_key if secret_key: kwargs['secret_key'] = secret_key if region: kwargs['region'] = region if aws_config_file: kwargs['aws_config_file'] = aws_config_file utilities.login(**kwargs) self.info('Successfully set AWS credentials') class VerifyCommand(Command): """ Verifies your ownership of an email address. Must be done prior to sending any messages verify {email-address : The email address that you want to verify} """ def handle(self): email = self.argument('email-address') verified = utilities.verify_address(email) if verified: self.info('This email address has already been verified') else: self.info(f'Email sent to {email}. You must click the link in this email to verify ownership before ' f'you can send any emails') class SendCommand(Command): """ Send an email to a list of recipients send {sender : The source email address (you must have verified ownership)} {subject : The subject line of the email} {--c|content=? : The content of the email to send} {--f|file-path=? : A path to a file containing content to send} {--t|theme : A path to a css file to be applied to the email} {recipients?* : A list of email addresses to send the mail to} """ def handle(self): sender = self.argument('sender') subject = self.argument('subject') content = self.option('content') file_path = self.option('file-path') theme = self.option('theme') recipients = self.argument('recipients') if not recipients: self.line('You must supply at least one recipient', 'error') return if not any([content, file_path]) or all([content, file_path]): self.line('You must provide either the content or file_path argument only', 'error') return kwargs = dict( sender=sender, subject=subject, content=content, file_path=file_path, to=recipients ) if theme: kwargs['theme'] = theme utilities.send_message(**kwargs) self.info('Messages added to queue') PK!T::maildown/renderer.pyimport os from typing import Optional from jinja2 import Template import mistune from pygments import highlight from pygments.lexers import get_lexer_by_name from pygments.formatters import html from premailer import transform class HighlightRenderer(mistune.Renderer): """ This highlight renderer improves the way code blocks are handled """ def block_code(self, code, lang=None): if not lang: return "\n
%s
\n" % mistune.escape(code) lexer = get_lexer_by_name(lang, stripall=True) formatter = html.HtmlFormatter() return highlight(code, lexer, formatter) def generate_content( md_content: str, theme: str = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style.css"), context: Optional[dict] = None, ): """ Generates the content of an email to be sent. This method actually renders two templates: 1. The extremely simple local template, which writes the stylesheet, header and user-provided md_content to the message 2. The result of 1. is also treated as a jinja template, and rendered using the arguments provided in the context parameter Apart from rendering the template, this method also does two other things: 1. Applies an additional highlight renderer with better support for code blocks 2. Uses premailer.transform to bake the css into the HTML """ if not context: context = {} with open(theme) as f: theme = f.read() markdown = mistune.Markdown(renderer=HighlightRenderer()) with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "template.jinja2"),) as t: template = Template(t.read()) content = transform(template.render(content=markdown(md_content), stylesheet=theme)) t = Template(content) return t.render(context) PK!Mmaildown/style.css body { font-family: "Avenir Next", Helvetica, Arial, sans-serif; padding:1em; margin:auto; max-width:42em; background:#fefefe; } h1, h2, h3, h4, h5, h6 { font-weight: bold; } h1 { color: #000000; font-size: 28pt; } h2 { border-bottom: 1px solid #CCCCCC; color: #000000; font-size: 24px; } h3 { font-size: 18px; } h4 { font-size: 16px; } h5 { font-size: 14px; } h6 { color: #777777; background-color: inherit; font-size: 14px; } hr { height: 0.2em; border: 0; color: #CCCCCC; background-color: #CCCCCC; } p, blockquote, ul, ol, dl, li, table, pre { margin: 15px 0; } img { max-width: 100%; } table { border-collapse: collapse; width: 100%; } table, th, td { border: 1px solid #EAEAEA; border-radius: 3px; padding: 5px; } tr:nth-child(even) { background-color: #F8F8F8; } a, a:visited { color: #4183C4; background-color: inherit; text-decoration: none; } #message { border-radius: 6px; border: 1px solid #ccc; display:block; width:100%; height:60px; margin:6px 0px; } button, #ws { font-size: 10pt; padding: 4px 6px; border-radius: 5px; border: 1px solid #bbb; background-color: #eee; } code, pre, #ws, #message { font-family: Monaco, monospace; font-size: 10pt; border-radius: 3px; background-color: #F8F8F8; color: inherit; } code { border: 1px solid #EAEAEA; margin: 0 2px; padding: 0 5px; } pre { border: 1px solid #CCCCCC; background-color: rgb(43, 43, 43); color: rgb(248, 248, 242); overflow: auto; padding: 4px 8px; } pre span.ch, pre span.c1 { color: grey; } pre span.kn, pre span.k, pre span.ow, pre span.p { color: orange } pre span.nd { color: gold } pre span.nf { color: yellow } pre span.s2, pre span.s1, pre span.si { color: green } pre span.nb, pre span.mi { color: violet } pre > code { border: 0; margin: 0; padding: 0; } #ws { background-color: #f8f8f8; } .send { color:#77bb77; } .server { color:#7799bb; } .error { color:#AA0000; } PK!1Imaildown/template.jinja2 {{ content }} PK!#qTTmaildown/utilities.pyimport os import toml from typing import Optional, Dict, Union, SupportsFloat import configparser import boto3 from botocore.exceptions import ClientError from maildown.renderer import generate_content def get_client() -> boto3.client: config = get_config() return boto3.client( "ses", aws_access_key_id=config.get("access_key"), aws_secret_access_key=config.get("secret_key"), region_name=config.get("region", "us-east-1"), ) def verify_address(email: str) -> bool: client = get_client() addresses = client.list_verified_email_addresses().get("VerifiedEmailAddresses") if email in addresses: return True client.verify_email_address(EmailAddress=email) return False def verify_auth( access_key: str, secret_key: str, region_name: str = "us-east-1" ) -> bool: """ Checks that the given credentials work by executing a simple boto3 command """ client = boto3.client( "ses", aws_access_key_id=access_key, aws_secret_access_key=secret_key, region_name=region_name, ) try: client.list_configuration_sets() return True except ClientError: return False def get_config() -> dict: """ Returns the existing configuration from the local """ try: with open(os.path.join(os.path.expanduser("~"), "maildown.toml")) as f: return toml.loads(f.read()) except FileNotFoundError: pass return {} def write_config(**config: Dict[str, Union[str, SupportsFloat, bool]]) -> None: """ Updates the existing local config with the given additional arguments """ existing = get_config() for key, val in config.items(): existing[key] = val with open(os.path.expanduser("~/maildown.toml"), "w") as f: f.write(toml.dumps(config)) def login( access_key: Optional[str] = None, secret_key: Optional[str] = None, region_name: str = "us-east-1", aws_config_file: str = os.path.expanduser("~/.aws/credentials"), ) -> None: """ Checks your AWS credentials are valid, and stores them locally if so for future re use. If you provide the access key/secret key arguments directly to this function, then these credentials will be taken in the first instance. If these arguments are NOT supplied, then this method will first check to see if the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environmental variables have been set. If not, this method will attempt to read the file kept at `aws_config_file`, which is the default location of the Amazon CLI config file. If this method cannot find credentials via any one of these methods, or if the credentials it does find are invalid, then an Exception is raised. However, if valid credentials can be found, these are stored locally TODO: replace all exceptions raised in this method with Maildown ones """ if not any([access_key, secret_key]): access_key = os.environ.get("AWS_ACCESS_KEY_ID") secret_key = os.environ.get("AWS_SECRET_ACCESS_KEY") if not any([access_key, secret_key]): config = configparser.ConfigParser() config.read(aws_config_file) try: access_key = config["default"].get("aws_access_key_id") secret_key = config["default"].get("aws_secret_access_key") except KeyError: raise KeyError( f"Cannot find expected keys in config file stored at {aws_config_file}" ) if not all([access_key, secret_key]): raise AttributeError( "No credentials supplied - you must either provide the `access_key`, and `secret_key` " "values, set the environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, or run " "`aws configure` and try again" ) if not verify_auth(access_key, secret_key, region_name): raise AttributeError("The supplied credentials are not valid") config = get_config() config["access_key"] = access_key config["secret_key"] = secret_key config["region_name"] = region_name write_config(**config) def send_message( sender: str, subject: str, to: list, content: Optional[str] = None, file_path: Optional[str] = None, context: Optional[dict] = None, theme=None, ): if not context: context = {} if all([content, file_path]) or not any([content, file_path]): raise AttributeError( "You must provide either the content or filepath attribute" ) if file_path: with open(file_path) as f: content = f.read() kwargs = dict(md_content=content, context=context) if theme: kwargs["theme"] = theme message = generate_content(**kwargs) client = get_client() return client.send_email( Source=sender, Destination=dict(ToAddresses=to), Message=dict( Body=dict( Html=dict(Charset="utf-8", Data=message), Text=dict(Charset="utf-8", Data=content), ), Subject=dict(Charset="utf-8", Data=subject), ), ) PK!H!f(-)maildown-1.0.0.dist-info/entry_points.txtN+I/N.,()MI/ϳ1ts2JPK!HڽTUmaildown-1.0.0.dist-info/WHEEL A н#Z;/"d&F[xzw@Zpy3Fv]\fi4WZ^EgM_-]#0(q7PK!H;c!maildown-1.0.0.dist-info/METADATAKO1Mhy  ,4:\ێʿDl瞦g /+Ji"2R*(CZBJJn6)R[`,&tj!TN Xr%%Qqv3}@_ʭIheu\꘿ ߥ`+,25iIl0`:jh{TpkRΙ.9*M~wɱRNn/=6ujPk{ ?3lFZ)iuXkukrKPԒz1G{()ea(lfD0bH+ょMp1k:G/{|PK!H˅Mmaildown-1.0.0.dist-info/RECORD}˖0}? \䶘 7$@ >MϩWU?N~( ^WY [`w_yL `=3:ȋ$樠ڰO A?#k"Ņ0"(K BbvPĊf# 2maildown-1.0.0.dist-info/entry_points.txtPK!HڽTU2maildown-1.0.0.dist-info/WHEELPK!H;c!=3maildown-1.0.0.dist-info/METADATAPK!H˅M4maildown-1.0.0.dist-info/RECORDPK 87