My Dotfiles
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

240 lines
8.3 KiB

  1. #!/usr/bin/env python3
  2. # Original Source: https://github.com/oblitum/dotfiles/blob/ArchLinux/.local/bin/MIMEmbellish
  3. import re
  4. import sys
  5. import email
  6. import shlex
  7. import mimetypes
  8. import subprocess
  9. from copy import copy
  10. from hashlib import md5
  11. from email import charset
  12. from email import encoders
  13. from email.mime.text import MIMEText
  14. from email.mime.multipart import MIMEMultipart
  15. from email.mime.nonmultipart import MIMENonMultipart
  16. from os.path import basename, splitext, expanduser
  17. charset.add_charset('utf-8', charset.SHORTEST, '8bit')
  18. def pandoc(from_format, to_format='markdown', plain='markdown', title=None):
  19. markdown = ('markdown'
  20. '-blank_before_blockquote')
  21. if from_format == 'plain':
  22. from_format = plain
  23. if from_format == 'markdown':
  24. from_format = markdown
  25. if to_format == 'markdown':
  26. to_format = markdown
  27. command = 'pandoc -f {} -t {} --standalone --highlight-style=tango'
  28. if to_format in ('html', 'html5'):
  29. if title is not None:
  30. command += ' --variable=pagetitle:{}'.format(shlex.quote(title))
  31. command += ' --webtex --template={}'.format(
  32. expanduser('~/.pandoc/templates/email.html'))
  33. return command.format(from_format, to_format)
  34. def gmailfy(payload):
  35. return payload.replace('<blockquote>',
  36. '<blockquote class="gmail_quote" style="'
  37. 'padding: 0 7px 0 7px;'
  38. 'border-left: 2px solid #cccccc;'
  39. 'font-style: italic;'
  40. 'margin: 0 0 7px 3px;'
  41. '">')
  42. def make_alternative(message, part):
  43. alternative = convert(part, 'html',
  44. pandoc(part.get_content_subtype(),
  45. to_format='html',
  46. title=message.get('Subject')))
  47. alternative.set_payload(gmailfy(alternative.get_payload()))
  48. return alternative
  49. def make_replacement(message, part):
  50. return convert(part, 'plain', pandoc(part.get_content_subtype()))
  51. def convert(part, to_subtype, command):
  52. payload = part.get_payload()
  53. if isinstance(payload, str):
  54. payload = payload.encode('utf-8')
  55. else:
  56. payload = part.get_payload(None, True)
  57. if not isinstance(payload, bytes):
  58. payload = payload.encode('utf-8')
  59. process = subprocess.run(
  60. shlex.split(command),
  61. input=payload, stdout=subprocess.PIPE, check=True)
  62. return MIMEText(process.stdout, to_subtype, 'utf-8')
  63. def with_alternative(parent, part, from_signed,
  64. make_alternative=make_alternative,
  65. make_replacement=None):
  66. try:
  67. alternative = make_alternative(parent or part, from_signed or part)
  68. replacement = (make_replacement(parent or part, part)
  69. if from_signed is None and make_replacement is not None
  70. else part)
  71. except:
  72. return parent or part
  73. envelope = MIMEMultipart('alternative')
  74. if parent is None:
  75. for k, v in part.items():
  76. if (k.lower() != 'mime-version'
  77. and not k.lower().startswith('content-')):
  78. envelope.add_header(k, v)
  79. del part[k]
  80. envelope.attach(replacement)
  81. envelope.attach(alternative)
  82. if parent is None:
  83. return envelope
  84. payload = parent.get_payload()
  85. payload[payload.index(part)] = envelope
  86. return parent
  87. def tag_attachments(message):
  88. if message.get_content_type() == 'multipart/mixed':
  89. for part in message.get_payload():
  90. if (part.get_content_maintype() in ['image']
  91. and 'Content-ID' not in part):
  92. filename = part.get_param('filename',
  93. header='Content-Disposition')
  94. if isinstance(filename, tuple):
  95. filename = str(filename[2], filename[0] or 'us-ascii')
  96. if filename:
  97. filename = splitext(basename(filename))[0]
  98. if filename:
  99. part.add_header('Content-ID', '<{}>'.format(filename))
  100. return message
  101. def attachment_from_file_path(attachment_path):
  102. try:
  103. mime, encoding = mimetypes.guess_type(attachment_path, strict=False)
  104. maintype, subtype = mime.split('/')
  105. with open(attachment_path, 'rb') as payload:
  106. attachment = MIMENonMultipart(maintype, subtype)
  107. attachment.set_payload(payload.read())
  108. encoders.encode_base64(attachment)
  109. if encoding:
  110. attachment.add_header('Content-Encoding', encoding)
  111. return attachment
  112. except:
  113. return None
  114. attachment_path_pattern = re.compile(r'\]\s*\(\s*file://(/[^)]*\S)\s*\)|'
  115. r'\]\s*:\s*file://(/.*\S)\s*$',
  116. re.MULTILINE)
  117. def link_attachments(payload):
  118. attached = []
  119. attachments = []
  120. def on_match(match):
  121. if match.group(1):
  122. attachment_path = match.group(1)
  123. cid_fmt = '](cid:{})'
  124. else:
  125. attachment_path = match.group(2)
  126. cid_fmt = ']: cid:{}'
  127. attachment_id = md5(attachment_path.encode()).hexdigest()
  128. if attachment_id in attached:
  129. return cid_fmt.format(attachment_id)
  130. attachment = attachment_from_file_path(attachment_path)
  131. if attachment:
  132. attachment.add_header('Content-ID', '<{}>'.format(attachment_id))
  133. attachments.append(attachment)
  134. attached.append(attachment_id)
  135. return cid_fmt.format(attachment_id)
  136. return match.group()
  137. return attachments, attachment_path_pattern.sub(on_match, payload)
  138. def with_local_attachments(parent, part, from_signed,
  139. link_attachments=link_attachments):
  140. if from_signed is None:
  141. attachments, payload = link_attachments(part.get_payload())
  142. part.set_payload(payload)
  143. else:
  144. attachments, payload = link_attachments(from_signed.get_payload())
  145. from_signed = copy(from_signed)
  146. from_signed.set_payload(payload)
  147. if not attachments:
  148. return parent, part, from_signed
  149. if parent is None:
  150. parent = MIMEMultipart('mixed')
  151. for k, v in part.items():
  152. if (k.lower() != 'mime-version'
  153. and not k.lower().startswith('content-')):
  154. parent.add_header(k, v)
  155. del part[k]
  156. parent.attach(part)
  157. for attachment in attachments:
  158. parent.attach(attachment)
  159. return parent, part, from_signed
  160. def is_target(part, target_subtypes):
  161. return (part.get('Content-Disposition', 'inline') == 'inline'
  162. and part.get_content_maintype() == 'text'
  163. and part.get_content_subtype() in target_subtypes)
  164. def pick_from_signed(part, target_subtypes):
  165. for from_signed in part.get_payload():
  166. if is_target(from_signed, target_subtypes):
  167. return from_signed
  168. def seek_target(message, target_subtypes=['plain', 'markdown']):
  169. if message.is_multipart():
  170. if message.get_content_type() == 'multipart/signed':
  171. part = pick_from_signed(message, target_subtypes)
  172. if part is not None:
  173. return None, message, part
  174. elif message.get_content_type() == 'multipart/mixed':
  175. for part in message.get_payload():
  176. if part.is_multipart():
  177. if part.get_content_type() == 'multipart/signed':
  178. from_signed = pick_from_signed(part, target_subtypes)
  179. if from_signed is not None:
  180. return message, part, from_signed
  181. elif is_target(part, target_subtypes):
  182. return message, part, None
  183. else:
  184. if is_target(message, target_subtypes):
  185. return None, message, None
  186. return None, None, None
  187. def main():
  188. try:
  189. message = email.message_from_file(sys.stdin)
  190. parent, part, from_signed = seek_target(message)
  191. if (parent, part, from_signed) == (None, None, None):
  192. print(message)
  193. return
  194. tag_attachments(message)
  195. print(with_alternative(
  196. *with_local_attachments(parent, part, from_signed)))
  197. except (BrokenPipeError, KeyboardInterrupt):
  198. pass
  199. if __name__ == '__main__':
  200. main()