Skip to content

Commit

Permalink
Merge 8490804 into 154e39e
Browse files Browse the repository at this point in the history
  • Loading branch information
kpfleming committed Jul 30, 2020
2 parents 154e39e + 8490804 commit a047705
Show file tree
Hide file tree
Showing 5 changed files with 454 additions and 296 deletions.
54 changes: 31 additions & 23 deletions README.md
Expand Up @@ -160,16 +160,36 @@ via SMTP.
* `suppress_empty`: if true, no email will be sent unless matching journal
entries are found (defaults to `true`)

* `from`: RFC-5322 format address to be used as the sender address (required)

* `to`: RFC-5322 format address to be used as the recipient address, or a list
of such addresses (required)

* `cc`: RFC-5322 format address to be used as a carbon-copy address,
or a list of such addresses

* `bcc`: RFC-5322 format address to be used as a blind-carbon-copy address,
or a list of such addresses

* `subject`: string to be used as the email message subject

* 'headers': dictionary of string keys and string values to be added as
custom headers; the dictionary cannot include 'From', 'To', 'Cc', or 'Bcc'

Either command-based or SMTP-based delivery must be specified (but not both).

### Email via command

Example:
```yaml
email:
command: "mail -s 'journal output' admin@example.com"
from: "journal sender" <journal@example.com>
to: "system admin" <admin@example.com>
command: "sendmail -i -t"
```

This will cause journal-brief to execute the specified command in a
child process and pipe the formatted journal entries to it. The supplied
child process and pipe the formatted email message to it. The supplied
command string will be executed via the shell (typically identified in the
`SHELL` environment variable) so it can make use of shell expansions and
other features.
Expand All @@ -179,41 +199,29 @@ other features.
Example:
```yaml
email:
smtp:
from: "journal sender" <journal@example.com>
to: "system admin" <admin@example.com>
from: "journal sender" <journal@example.com>
to: "system admin" <admin@example.com>
smtp: {}
```

This will cause journal-brief to use the Python `smtplib` module to send
the formatted output to `admin@example.com`.
the formatted email message.

#### Email SMTP configuration keys

* `from`: RFC-5322 format address to be used as the sender address (required)

* `to`: RFC-5322 format address to be used as the recipient address, or a list
of such addresses (required)

* `cc`: RFC-5322 format address to be used as a carbon-copy address,
or a list of such addresses
Note the usage of YAML 'flow' style to specify an empty mapping for the
'smtp' configuration, allowing all of the defaults to be used. This is only
necessary when no SMTP-specific configuration keys are specified.

* `bcc`: RFC-5322 format address to be used as a blind-carbon-copy address,
or a list of such addresses

* `subject`: string to be used as the email message subject
#### Email SMTP configuration keys

* `host`: hostname or address of the SMTP server to use for sending email
(defaults to `localhost`)

* `port`: port number to connect to on the SMTP server (defaults to `25`)

* `starttls`: boolean value indicating whether STARTTLS should be used to
secure the connection to the SMTP server
secure the connection to the SMTP server (defaults to 'false')

* `user`: username to be used to authenticate to the SMTP server

* `password`: password to be used to authenticate to the SMTP server (only
used if `user` is specified)

* 'headers': dictionary of string keys and string values to be added as
custom headers; the dictionary cannot include 'From', 'To', 'Cc', or 'Bcc'
42 changes: 26 additions & 16 deletions journal_brief/cli/main.py
Expand Up @@ -218,30 +218,40 @@ def send_email(self, output):
else:
return

if 'command' in email: # delivery through command
if not self.config.get('mime-email'):
# old-style non-MIME delivery through command
if self.args.dry_run:
print("Email to be delivered via '{0}'".format(email['command']))
print(EMAIL_DRY_RUN_SEPARATOR)
print(output)
else:
subprocess.run(email['command'], shell=True, check=True, text=True, input=output)
return

charset.add_charset('utf-8', charset.QP, charset.QP)
message = MIMEText(output, _charset='utf-8')
message['X-Journal-Brief-Version'] = journal_brief_version
message['From'] = email['from']
message['To'] = ', '.join(email['to'])
if 'subject' in email:
message['Subject'] = email['subject']
if 'cc' in email:
message['Cc'] = ', '.join(email['cc'])
if 'bcc' in email:
message['Bcc'] = ', '.join(email['bcc'])
if 'headers' in email:
for header, value in email['headers'].items():
message[header] = value

if 'command' in email: # delivery through command
if self.args.dry_run:
print("Email to be delivered via '{0}'".format(email['command']))
print(EMAIL_DRY_RUN_SEPARATOR)
print(message)
else:
subprocess.run(email['command'], shell=True, check=True, text=True, input=message)
else: # delivery via SMTP
smtp = email['smtp']
charset.add_charset('utf-8', charset.QP, charset.QP)
message = MIMEText(output, _charset='utf-8')
message['X-Journal-Brief-Version'] = journal_brief_version
message['From'] = smtp['from']
message['To'] = ', '.join(smtp['to'])
if 'subject' in smtp:
message['Subject'] = smtp['subject']
if 'cc' in smtp:
message['Cc'] = ', '.join(smtp['cc'])
if 'bcc' in smtp:
message['Bcc'] = ', '.join(smtp['bcc'])
if 'headers' in smtp:
for header, value in smtp['headers'].items():
message[header] = value

if self.args.dry_run:
print("Email to be delivered via SMTP to {0} port {1}".format(
smtp.get('host', 'localhost'),
Expand Down
143 changes: 80 additions & 63 deletions journal_brief/config.py
Expand Up @@ -184,22 +184,22 @@ def validate_debug(self):

def validate_email(self):
ALLOWED_EMAIL_KEYWORDS = {
'bcc',
'cc',
'command',
'from',
'headers',
'smtp',
'subject',
'suppress_empty',
'to',
}

ALLOWED_SMTP_KEYWORDS = {
'bcc',
'cc',
'from',
'headers',
'host',
'password',
'port',
'starttls',
'subject',
'to',
'user',
}

Expand All @@ -214,6 +214,7 @@ def validate_email(self):
return

email = self.get('email')
self['mime-email'] = True

if not isinstance(email, dict):
yield SemanticError('must be a map', 'email',
Expand All @@ -233,19 +234,32 @@ def validate_email(self):
email['suppress_empty'] = True

if ('smtp' in email and 'command' in email):
yield SemanticError('cannot specify both smtp and command', 'command',
yield SemanticError('cannot specify both smtp and command', 'email',
{'email':
{'command': email['command'],
'smtp': email['smtp']}})

if ('command' in email and
not isinstance(email['command'], str)):
yield SemanticError('expected string', 'command',
{'email': {'command': email['command']}})
if not ('smtp' in email or 'command' in email):
yield SemanticError('either smtp or command must be specified', 'email',
{'email': email})

if 'command' in email:
if not isinstance(email['command'], str):
yield SemanticError('expected string', 'command',
{'email': {'command': email['command']}})

# allow old-style non-MIME configuration for command delivery
if not ('from' in email or 'to' in email):
self['mime-email'] = False

if 'smtp' in email:
smtp = email['smtp']

# convert old-style configuration to new-style
for key in ['from', 'to', 'cc', 'bcc', 'subject', 'headers']:
if key in smtp:
email[key] = smtp.pop(key)

if not isinstance(smtp, dict):
yield SemanticError('must be a map', 'smtp',
{'email': {'smtp': smtp}})
Expand All @@ -255,49 +269,6 @@ def validate_email(self):
yield SemanticError('unexpected \'smtp\' keyword', unexpected_key,
{unexpected_key: smtp[unexpected_key]})

if 'from' not in smtp:
yield SemanticError('\'smtp\' map must include \'from\'', 'smtp',
{'smtp': smtp})
else:
if not isinstance(smtp['from'], str):
yield SemanticError('expected string', 'from',
{'smtp': {'from': smtp['from']}})

if 'to' not in smtp:
yield SemanticError('\'smtp\' map must include \'to\'', 'smtp',
{'smtp': smtp})
else:
if isinstance(smtp['to'], list):
pass
elif isinstance(smtp['to'], str):
smtp['to'] = [smtp['to']]
else:
yield SemanticError('expected list or string', 'to',
{'smtp': {'to': smtp['to']}})

if 'cc' in smtp:
if isinstance(smtp['cc'], list):
pass
elif isinstance(smtp['cc'], str):
smtp['cc'] = [smtp['cc']]
else:
yield SemanticError('expected list or string', 'cc',
{'smtp': {'cc': smtp['cc']}})

if 'bcc' in smtp:
if isinstance(smtp['bcc'], list):
pass
elif isinstance(smtp['bcc'], str):
smtp['bcc'] = [smtp['bcc']]
else:
yield SemanticError('expected list or string', 'bcc',
{'smtp': {'bcc': smtp['bcc']}})

if ('subject' in smtp and
not isinstance(smtp['subject'], str)):
yield SemanticError('expected string', 'subject',
{'smtp': {'subject': smtp['subject']}})

if ('host' in smtp and
not isinstance(smtp['host'], str)):
yield SemanticError('expected string', 'host',
Expand All @@ -324,15 +295,61 @@ def validate_email(self):
yield SemanticError('expected string', 'password',
{'smtp': {'password': smtp['password']}})

if 'headers' in smtp:
if not isinstance(smtp['headers'], dict):
yield SemanticError('expected dict', 'headers',
{'smtp': {'headers': smtp['headers']}})
else:
for key in smtp['headers'].keys():
if key.casefold() in DISALLOWED_HEADERS:
yield SemanticError("Header " + key + " cannot not be specified here", 'headers',
{'smtp': {'headers': smtp['headers']}})
if not self['mime-email']:
return

if 'from' not in email:
yield SemanticError('\'email\' map must include \'from\'', 'email',
{'email': email})
else:
if not isinstance(email['from'], str):
yield SemanticError('expected string', 'from',
{'email': {'from': email['from']}})

if 'to' not in email:
yield SemanticError('\'email\' map must include \'to\'', 'email',
{'email': email})
else:
if isinstance(email['to'], list):
pass
elif isinstance(email['to'], str):
email['to'] = [email['to']]
else:
yield SemanticError('expected list or string', 'to',
{'email': {'to': email['to']}})

if 'cc' in email:
if isinstance(email['cc'], list):
pass
elif isinstance(email['cc'], str):
email['cc'] = [email['cc']]
else:
yield SemanticError('expected list or string', 'cc',
{'email': {'cc': email['cc']}})

if 'bcc' in email:
if isinstance(email['bcc'], list):
pass
elif isinstance(email['bcc'], str):
email['bcc'] = [email['bcc']]
else:
yield SemanticError('expected list or string', 'bcc',
{'email': {'bcc': email['bcc']}})

if ('subject' in email and
not isinstance(email['subject'], str)):
yield SemanticError('expected string', 'subject',
{'email': {'subject': email['subject']}})

if 'headers' in email:
if not isinstance(email['headers'], dict):
yield SemanticError('expected dict', 'headers',
{'email': {'headers': email['headers']}})
else:
for key in email['headers'].keys():
if key.casefold() in DISALLOWED_HEADERS:
yield SemanticError("Header " + key + " cannot not be specified here", 'headers',
{'email': {'headers': email['headers']}})

def validate_output(self):
if 'output' not in self:
Expand Down

0 comments on commit a047705

Please sign in to comment.