239 lines
8.2 KiB
Plaintext
239 lines
8.2 KiB
Plaintext
|
#!/usr/local/bin/python3.6
|
||
|
|
||
|
import re
|
||
|
import sys
|
||
|
import email
|
||
|
import shlex
|
||
|
import mimetypes
|
||
|
import subprocess
|
||
|
from copy import copy
|
||
|
from hashlib import md5
|
||
|
from email import charset
|
||
|
from email import encoders
|
||
|
from email.mime.text import MIMEText
|
||
|
from email.mime.multipart import MIMEMultipart
|
||
|
from email.mime.nonmultipart import MIMENonMultipart
|
||
|
from os.path import basename, splitext, expanduser
|
||
|
|
||
|
|
||
|
charset.add_charset('utf-8', charset.SHORTEST, '8bit')
|
||
|
|
||
|
|
||
|
def pandoc(from_format, to_format='markdown', plain='markdown', title=None):
|
||
|
markdown = ('markdown'
|
||
|
'-blank_before_blockquote')
|
||
|
|
||
|
if from_format == 'plain':
|
||
|
from_format = plain
|
||
|
if from_format == 'markdown':
|
||
|
from_format = markdown
|
||
|
if to_format == 'markdown':
|
||
|
to_format = markdown
|
||
|
|
||
|
command = 'pandoc -f {} -t {} --standalone --highlight-style=tango'
|
||
|
if to_format in ('html', 'html5'):
|
||
|
if title is not None:
|
||
|
command += ' --variable=pagetitle:{}'.format(shlex.quote(title))
|
||
|
command += ' --webtex --template={}'.format(
|
||
|
expanduser('~/.pandoc/templates/email.html'))
|
||
|
return command.format(from_format, to_format)
|
||
|
|
||
|
|
||
|
def gmailfy(payload):
|
||
|
return payload.replace('<blockquote>',
|
||
|
'<blockquote class="gmail_quote" style="'
|
||
|
'padding: 0 7px 0 7px;'
|
||
|
'border-left: 2px solid #cccccc;'
|
||
|
'font-style: italic;'
|
||
|
'margin: 0 0 7px 3px;'
|
||
|
'">')
|
||
|
|
||
|
|
||
|
def make_alternative(message, part):
|
||
|
alternative = convert(part, 'html',
|
||
|
pandoc(part.get_content_subtype(),
|
||
|
to_format='html',
|
||
|
title=message.get('Subject')))
|
||
|
alternative.set_payload(gmailfy(alternative.get_payload()))
|
||
|
return alternative
|
||
|
|
||
|
|
||
|
def make_replacement(message, part):
|
||
|
return convert(part, 'plain', pandoc(part.get_content_subtype()))
|
||
|
|
||
|
|
||
|
def convert(part, to_subtype, command):
|
||
|
payload = part.get_payload()
|
||
|
if isinstance(payload, str):
|
||
|
payload = payload.encode('utf-8')
|
||
|
else:
|
||
|
payload = part.get_payload(None, True)
|
||
|
if not isinstance(payload, bytes):
|
||
|
payload = payload.encode('utf-8')
|
||
|
process = subprocess.run(
|
||
|
shlex.split(command),
|
||
|
input=payload, stdout=subprocess.PIPE, check=True)
|
||
|
return MIMEText(process.stdout, to_subtype, 'utf-8')
|
||
|
|
||
|
|
||
|
def with_alternative(parent, part, from_signed,
|
||
|
make_alternative=make_alternative,
|
||
|
make_replacement=None):
|
||
|
try:
|
||
|
alternative = make_alternative(parent or part, from_signed or part)
|
||
|
replacement = (make_replacement(parent or part, part)
|
||
|
if from_signed is None and make_replacement is not None
|
||
|
else part)
|
||
|
except:
|
||
|
return parent or part
|
||
|
envelope = MIMEMultipart('alternative')
|
||
|
if parent is None:
|
||
|
for k, v in part.items():
|
||
|
if (k.lower() != 'mime-version'
|
||
|
and not k.lower().startswith('content-')):
|
||
|
envelope.add_header(k, v)
|
||
|
del part[k]
|
||
|
envelope.attach(replacement)
|
||
|
envelope.attach(alternative)
|
||
|
if parent is None:
|
||
|
return envelope
|
||
|
payload = parent.get_payload()
|
||
|
payload[payload.index(part)] = envelope
|
||
|
return parent
|
||
|
|
||
|
|
||
|
def tag_attachments(message):
|
||
|
if message.get_content_type() == 'multipart/mixed':
|
||
|
for part in message.get_payload():
|
||
|
if (part.get_content_maintype() in ['image']
|
||
|
and 'Content-ID' not in part):
|
||
|
filename = part.get_param('filename',
|
||
|
header='Content-Disposition')
|
||
|
if isinstance(filename, tuple):
|
||
|
filename = str(filename[2], filename[0] or 'us-ascii')
|
||
|
if filename:
|
||
|
filename = splitext(basename(filename))[0]
|
||
|
if filename:
|
||
|
part.add_header('Content-ID', '<{}>'.format(filename))
|
||
|
return message
|
||
|
|
||
|
|
||
|
def attachment_from_file_path(attachment_path):
|
||
|
try:
|
||
|
mime, encoding = mimetypes.guess_type(attachment_path, strict=False)
|
||
|
maintype, subtype = mime.split('/')
|
||
|
with open(attachment_path, 'rb') as payload:
|
||
|
attachment = MIMENonMultipart(maintype, subtype)
|
||
|
attachment.set_payload(payload.read())
|
||
|
encoders.encode_base64(attachment)
|
||
|
if encoding:
|
||
|
attachment.add_header('Content-Encoding', encoding)
|
||
|
return attachment
|
||
|
except:
|
||
|
return None
|
||
|
|
||
|
|
||
|
attachment_path_pattern = re.compile(r'\]\s*\(\s*file://(/[^)]*\S)\s*\)|'
|
||
|
r'\]\s*:\s*file://(/.*\S)\s*$',
|
||
|
re.MULTILINE)
|
||
|
|
||
|
|
||
|
def link_attachments(payload):
|
||
|
attached = []
|
||
|
attachments = []
|
||
|
|
||
|
def on_match(match):
|
||
|
if match.group(1):
|
||
|
attachment_path = match.group(1)
|
||
|
cid_fmt = '](cid:{})'
|
||
|
else:
|
||
|
attachment_path = match.group(2)
|
||
|
cid_fmt = ']: cid:{}'
|
||
|
attachment_id = md5(attachment_path.encode()).hexdigest()
|
||
|
if attachment_id in attached:
|
||
|
return cid_fmt.format(attachment_id)
|
||
|
attachment = attachment_from_file_path(attachment_path)
|
||
|
if attachment:
|
||
|
attachment.add_header('Content-ID', '<{}>'.format(attachment_id))
|
||
|
attachments.append(attachment)
|
||
|
attached.append(attachment_id)
|
||
|
return cid_fmt.format(attachment_id)
|
||
|
return match.group()
|
||
|
|
||
|
return attachments, attachment_path_pattern.sub(on_match, payload)
|
||
|
|
||
|
|
||
|
def with_local_attachments(parent, part, from_signed,
|
||
|
link_attachments=link_attachments):
|
||
|
if from_signed is None:
|
||
|
attachments, payload = link_attachments(part.get_payload())
|
||
|
part.set_payload(payload)
|
||
|
else:
|
||
|
attachments, payload = link_attachments(from_signed.get_payload())
|
||
|
from_signed = copy(from_signed)
|
||
|
from_signed.set_payload(payload)
|
||
|
if not attachments:
|
||
|
return parent, part, from_signed
|
||
|
if parent is None:
|
||
|
parent = MIMEMultipart('mixed')
|
||
|
for k, v in part.items():
|
||
|
if (k.lower() != 'mime-version'
|
||
|
and not k.lower().startswith('content-')):
|
||
|
parent.add_header(k, v)
|
||
|
del part[k]
|
||
|
parent.attach(part)
|
||
|
for attachment in attachments:
|
||
|
parent.attach(attachment)
|
||
|
return parent, part, from_signed
|
||
|
|
||
|
|
||
|
def is_target(part, target_subtypes):
|
||
|
return (part.get('Content-Disposition', 'inline') == 'inline'
|
||
|
and part.get_content_maintype() == 'text'
|
||
|
and part.get_content_subtype() in target_subtypes)
|
||
|
|
||
|
|
||
|
def pick_from_signed(part, target_subtypes):
|
||
|
for from_signed in part.get_payload():
|
||
|
if is_target(from_signed, target_subtypes):
|
||
|
return from_signed
|
||
|
|
||
|
|
||
|
def seek_target(message, target_subtypes=['plain', 'markdown']):
|
||
|
if message.is_multipart():
|
||
|
if message.get_content_type() == 'multipart/signed':
|
||
|
part = pick_from_signed(message, target_subtypes)
|
||
|
if part is not None:
|
||
|
return None, message, part
|
||
|
elif message.get_content_type() == 'multipart/mixed':
|
||
|
for part in message.get_payload():
|
||
|
if part.is_multipart():
|
||
|
if part.get_content_type() == 'multipart/signed':
|
||
|
from_signed = pick_from_signed(part, target_subtypes)
|
||
|
if from_signed is not None:
|
||
|
return message, part, from_signed
|
||
|
elif is_target(part, target_subtypes):
|
||
|
return message, part, None
|
||
|
else:
|
||
|
if is_target(message, target_subtypes):
|
||
|
return None, message, None
|
||
|
return None, None, None
|
||
|
|
||
|
|
||
|
def main():
|
||
|
try:
|
||
|
message = email.message_from_file(sys.stdin)
|
||
|
parent, part, from_signed = seek_target(message)
|
||
|
if (parent, part, from_signed) == (None, None, None):
|
||
|
print(message)
|
||
|
return
|
||
|
tag_attachments(message)
|
||
|
print(with_alternative(
|
||
|
*with_local_attachments(parent, part, from_signed)))
|
||
|
except (BrokenPipeError, KeyboardInterrupt):
|
||
|
pass
|
||
|
|
||
|
|
||
|
if __name__ == '__main__':
|
||
|
main()
|