PK ! yVP P maildown/__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 ! M maildown/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 ! 1I maildown/template.jinja2
{{ content }}
PK ! #qT T maildown/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/ϳ1ts2J PK !HڽT U maildown-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!TNXr%%Qqv3}@_ʭIheu\ߥ`+ ,25iIl0`:jh{TpkR Ι.9*M~wɱ