Skip to content

Instantly share code, notes, and snippets.

@tausen
Last active March 29, 2020 11:37
Show Gist options
  • Save tausen/b1c4b2f7eae9d96d29dc378fa2ec4eca to your computer and use it in GitHub Desktop.
Save tausen/b1c4b2f7eae9d96d29dc378fa2ec4eca to your computer and use it in GitHub Desktop.
Dirty, dirty hacks to support Outlook HTML reply styles in mu4e
#!/usr/bin/env python3
import os
import sys
import re
import base64
import subprocess
import html
import mailparser # mail-parser
import markdown # Markdown
import magic # python-magic (for mimetypes)
QUOTE_ESCAPE = "MIMELOOK_QUOTES"
mime = magic.Magic(mime=True)
def export_inline_attachments(message, dstdir):
# find inline attachments in plaintext version
#inlines = re.findall("\[cid:.*?\]", message.body.split("--- mail_boundary ---")[0])
# .. or html version (probably safer?):
inlines = re.findall("src=\"cid:.*?\"", message.body.split("--- mail_boundary ---")[1])
# return list of tuples: (attachment id, exported file)
ret = []
for inline in inlines:
# find filename
name_match = re.search("cid:.*@", inline)
# find content id
id_match = re.search("@.*", inline)
attachment_id = inline[id_match.start()+1:-1]
# find corresponding attachment in the message
attachment_name = inline[name_match.start()+4:name_match.end()-1]
attachment = [x for x in message.attachments if x["filename"] == attachment_name]
assert len(attachment) == 1, "Could not get attachment '{}'".format(attachment_name)
attachment = attachment[0]
# base64 decode the file and place it in dstdir
assert attachment["content_transfer_encoding"] == "base64", "Only base64 currently supported"
b = base64.decodebytes(bytes(attachment["payload"], "ascii"))
dstfile = os.path.join(dstdir, attachment_name)
with open(dstfile, "wb") as f:
f.write(b)
# same attachment might occur multiple times - only add it once
att = (attachment_id, dstfile)
if att not in ret:
ret.append(att)
return ret
# make "from: x, sent: d/m-y, to: z, ..." section in outlook style
# grabbed from what outlook webmail does
def format_insane_outlook_header(fromaddr, sent, to, cc, subject):
ret = """<hr style="display:inline-block;width:98%" tabindex="-1">
<div id="divRplyFwdMsg" dir="ltr"><font face="Calibri, sans-serif" style="font-size:11pt" color="#000000"><b>From:</b> {}<br>
<b>Sent:</b> {}<br>
""".format(fromaddr, sent)
if to is not None:
ret += "<b>To:</b> {}<br>\n".format(to)
if cc is not None:
ret += "<b>Cc:</b> {}<br>\n".format(cc)
ret += """<b>Subject:</b> {}</font>
<div>&nbsp;</div>
</div>
""".format(subject)
return ret
# get message from id
def message_from_msgid(msgid):
mucmd = "mu find msgid:{} --fields 'l'".format(msgid)
p = subprocess.Popen(mucmd.split(" "), stdout=subprocess.PIPE)
messagefiles, _ = p.communicate()
messagefiles = messagefiles.decode("utf-8").split("\n")
# check return code ok
assert p.returncode == 0, "mu find failed"
# expecting list of at least 1 message and one empty line
assert(len(messagefiles) > 1), "mu found no messages"
# use first hit and strip surrounding '
messagefile = messagefiles[0][1:-1]
# parse message and grab HTML
message = mailparser.parse_from_file(messagefile)
return message
# create crazy outlook-style html reply from message id and the desired html message
def format_outlook_reply(message, htmltoinsert):
message_html = message.body.split("--- mail_boundary ---")[1]
# convert CRLF to LF
message_html = message_html.replace("\r\n", "\n")
# grab header info
message_from = message.headers["From"]
message_to = message.headers["To"] if "To" in message.headers else None
message_subject = message.headers["Subject"]
message_date = message.date.strftime("%d %B %Y %H:%M:%S")
message_cc = message.headers["CC"] if "CC" in message.headers else None
outlook_madness = format_insane_outlook_header(message_from, message_date,
message_to, message_cc, message_subject)
# find body tag in html
m = re.search("<body.*?>", message_html)
assert m is not None, "No body tag found in parent HTML"
# format resulting html email:
# ..<body> from email being replied to
# reply message
# "from yada yada" section
# remainder of email being replied to
html = "{}\n{}\n{}\n{}".format(message_html[:m.end()],
htmltoinsert,
outlook_madness,
message_html[m.end():])
return html
# Convert "> "-style quotes into something else that passes untouched through html.escape()
# Escaped quotes look like this: [[MIMELOOK_QUOTES|X]] where X denotes the
# number of quotes that have been escaped
def escape_quotes(plaintext):
ret = ""
for line in plaintext.split("\n"):
if line.startswith(">"):
i = 0
while i < len(line) and line[i] == ">":
i += 1
ret += "[[{}|{}]]".format(QUOTE_ESCAPE, i)
ret += line[i:] + "\n"
else:
ret += line + "\n"
return ret
# Convert previously escaped "> "-style quotes back to their original form.
def unescape_quotes(string):
retstr = ""
i = 0
while i < len(string):
# find start position of next escaped quote group
p = string[i:].find("[[{}|".format(QUOTE_ESCAPE))
if p < 0:
# no more escaped quotes - grab the rest of the string and return
retstr += string[i:]
break
# found some escaped quotes, grab content of string upto them
retstr += string[i:i+p]
# find end of the escaped quote tag
tag_end_pos = string[i+p:].find("]]")
# grab the number from the tag
nquotes = int(string[i+p+2+len(QUOTE_ESCAPE)+1:i+p+tag_end_pos])
# append that number of quotes
retstr += ">"*nquotes
# advance to the character just after the tag
i += p+tag_end_pos+2
return retstr
def escape_signature_linebreaks(plaintext):
m = re.search("^-- ", plaintext, re.MULTILINE)
if m is not None:
content = plaintext[:m.start()]
signature = plaintext[m.start():]
signature = signature.replace("\n", " \n")
return content + signature
else:
return plaintext
# Find MIME parts in the plaintext
# Returns plaintext without parts and list of parts
# Warning: assumes only valid <#part...><#/part> tags after the first occurence
# of "<#part" !
def find_mime_parts(plaintext):
parts = re.findall("<#part.*?<#/part>", plaintext, re.DOTALL)
m = re.search("<#part.*?<#/part>", plaintext, re.DOTALL)
if m is not None:
text = plaintext[:m.start()]
else:
text = plaintext
return text, parts
# Take desired plaintext message and id of message being replied to
# and format a multipart message with sane plaintext section and
# insane outlook-style html section. The plaintext message is converted
# to HTML supporting markdown syntax.
def plain2fancy(plaintext, msgid):
# find and strip MIME parts in the ending of the plaintext
plaintext, parts = find_mime_parts(plaintext)
# escape HTML in the plaintext, handling quoted content explicitly
escaped_plaintext = unescape_quotes(html.escape(escape_quotes(plaintext)))
# handle signature - we expect linebreaks to be preserved in the signature,
# but let everything else wrap (reminder: Markdown preserves linebreaks if
# there's two spaces at the end of a line)
escaped_plaintext = escape_signature_linebreaks(escaped_plaintext)
# plaintext is converted to html, supporting markdown syntax
# loosely inspired by http://webcache.googleusercontent.com/search?q=cache:R1RQkhWqwEgJ:tess.oconnor.cx/2008/01/html-email-composition-in-emacs
text2html = markdown.markdown(escaped_plaintext)
# get message from message id
message = message_from_msgid(msgid)
# insane outlook-style html reply
madness = format_outlook_reply(message, text2html)
# find inline attachments and export them to a temporary dir
attdir = "/dev/shm/mu4e-{}".format(msgid)
if not os.path.isdir(attdir):
os.mkdir(attdir)
attachments = export_inline_attachments(message, attdir)
# build string of <#part type=x filename=y disposition=inline><#/part> for each
# attachment, separated by newlines
attachment_str = ""
for attachment in attachments:
mimetype = mime.from_file(attachment[1])
attachment_str += "<#part type=\"{}\" filename=\"{}\" disposition=inline id=\"{}@{}\"><#/part>\n"\
.format(mimetype, attachment[1], os.path.basename(attachment[1]), attachment[0])
# also include attachments that were already present in the plaintext
attachment_str += "\n".join(parts)
# write html message to file for inspection before sending
with open("/dev/shm/mimelook-madness.html", "w") as f:
f.write(madness)
# return the multipart message
multimsg = """<#multipart type=alternative>
<#part type=text/plain>
{}
<#/part>
<#part type=text/html>
{}
<#/part>
<#/multipart>
{}""".format(plaintext, madness, attachment_str)
return multimsg
if __name__ == '__main__':
stdin = sys.stdin.read()
msgid_end_char = stdin.find("\n")
# expecting first line to be the message id
msgid = stdin[:msgid_end_char]
# expecting rest to be plaintext version of the message
plaintext = stdin[msgid_end_char+1:]
print(plain2fancy(plaintext, msgid))
;; WARNING: This is a terrible hack written in an attempt to support the
;; terrible hacks implemented by Outlook. The author takes no responsibility and
;; users are encouraged to closely inspect the generated message before sending!
;; Not much is going on in lisp: We need a reference to the parent message (the
;; message being replied to) so we can forward its ID to mimelook.py. mu4e
;; exposes the message being replied to in the variable
;; `mu4e-compose-parent-message' in the pre-compose hook, so we grab it, store it
;; in `my/mu4e-parent-message', and then store it in a local variable in the
;; `mu4e-compose-mode-hook'.
;; Before sending, we call `my/mu4e-outlook-madness'. This will take the current
;; body text and parent message ID from the buffer and send it to an external
;; Python script (mimelook.py). This script then does several things:
;; 1) It generates an HTML version of the body text (assuming it is Markdown
;; syntax, with the rationale that "the single biggest source of inspiration for
;; Markdown’s syntax is the format of plain text email" [1]).
;; 2) It finds the parent message using mu and the message id and parses it
;; using the Python mailparser module.
;; 3) It fetches all inline attachments (usually images) of the parent message,
;; which is commonly used in Outlook users' signatures, and stores them in a
;; directory in /dev/shm/.
;; 4) It grabs the HTML body text of the parent message
;; Finally, a multipart message is generated with the structure:
;; <#multipart type=alternative>
;; <<untouched plaintext that was in the buffer before calling `my/mu4e-outlook-madness'>>
;; <#part type=text/html>
;; <<everything in the parent message HTML upto and including the <body> tag>>
;; <<the plaintext from our buffer, converted to HTML via Markdown>>
;; <<an Outlook-style message citation block with "From: x, To: y, Subject: z, ...">>
;; <<everything in the parent message HTML from (but excluding) the <body> tag>>
;; <#/multipart>
;; <<a <#part ...><#/part> for each inline attachment>>
;; This message overwrites the content of the buffer, which can then be sent as
;; usual (usually with C-c C-c).
;; The result is a message that has a nice, sane plaintext version as we wrote
;; it in mu4e. It also has an insane HTML version that renders nicely for
;; Outlook users and contains the entire mail thread being replied to below the
;; message being sent, as Outlook users usually expect.
;; For debugging, the HTML part is written to /dev/shm/mimelook-madness.html for
;; inspection. This file will automatically be opened in a browser if
;; `my/mu4e-outlook-madness' is called with a prefix argument. Do note that inline
;; attachment images are NOT rendered in this preview!
;; If an Outlook user sends us a plaintext email, don't attempt to produce an
;; HTML reply with `my/mu4e-outlook-madness'. Instead refer to
;; `my/enable-outlook-reply-style', which will set
;; `message-citation-line-function' to an Outlook-style (but plaintext) citation
;; line (will also change yank prefixes). These are bound to C-c C-S-o and C-c
;; C-o.
;; [1]: https://daringfireball.net/projects/markdown/
(defun my/mu4e-pre-compose-set-parent-message ()
"When composing, detect the message being replied to and store
it in the global (and unsafe to use) variable
`my/mu4e-parent-message'."
(when (> (length mu4e-compose-parent-message) 0)
(setq my/mu4e-parent-message mu4e-compose-parent-message)))
(add-hook 'mu4e-compose-pre-hook 'my/mu4e-pre-compose-set-parent-message)
(defun my/mu4e-post-compose-set-parent-message ()
"When composing, use `my/mu4e-parent-message' to store the parent
message in the local variable `my/mu4e-local-parent-message'."
(message "my/mu4e-post-compose-set-parent-message")
(when (> (length mu4e-compose-parent-message) 0)
(make-local-variable 'my/mu4e-local-parent-message)
(setq my/mu4e-local-parent-message mu4e-compose-parent-message)))
(add-hook 'mu4e-compose-mode-hook 'my/mu4e-post-compose-set-parent-message)
(defun my/mu4e-outlook-madness (x)
"Convert message in current buffer to a multipart message where
the HTML version loosely conforms to the style used by MS
outlook. This depends on the external script mimelook.py that
has to be in PATH. With prefix argument, open browser for
preview.
Not compatible with `my/message-insert-outlook-citation-line'."
(interactive "P")
(save-excursion
(message-goto-body)
(insert (mu4e-message-field my/mu4e-local-parent-message :message-id))
(newline)
(message-goto-body)
(shell-command-on-region (point) (point-max) "mimelook.py" nil t)
(when x
(message "Opening HTML preview - inline images are NOT rendered!")
(browse-url "file:///dev/shm/mimelook-madness.html"))))
;; when in mu4e-compose mode, bind my/mu4e-outlook-madness to C-c m
(add-hook 'mu4e-compose-mode-hook (lambda () (local-set-key (kbd "C-c m") 'my/mu4e-outlook-madness)))
;; confirmation before send in case we forget calling my/mu4e-outlook-madness
(add-hook 'message-send-hook
(lambda ()
(unless (yes-or-no-p "Send mail?")
(signal 'quit nil))))
(defun my/message-insert-outlook-citation-line ()
"Based off `message-insert-citation-line'. Inserts outlook (web)-style replies.
Not compatible with `my/mu4e-outlook-madness'"
(when message-reply-headers
(newline)
(insert "________________________________")
(newline)
(insert "From: " (replace-regexp-in-string " <.*>" "" (mail-header-from message-reply-headers)))
(newline)
(insert "Sent: "
(let ((tstr (parse-time-string
(substring
(replace-regexp-in-string "T" " " (mail-header-date message-reply-headers))
0 -5))))
(format-time-string "%e %b %G %H:%M:%S" (apply 'encode-time tstr))))
(newline)
(setq value "")
(insert "To: " (substring (concat (dolist (elt (mu4e-message-field my/mu4e-parent-message :to) value)
(setq value (concat value (car elt) "; "))))
0 -2))
(when (> (length (mu4e-message-field my/mu4e-parent-message :cc)) 0)
(newline)
(setq value "")
(insert "Cc: " (substring (concat (dolist (elt (mu4e-message-field my/mu4e-parent-message :cc) value)
(setq value (concat value (car elt) "; "))))
0 -2)))
(newline)
(insert "Subject: " (mail-header-subject message-reply-headers))
(newline)(newline)(newline)))
(defun my/enable-outlook-reply-style ()
"Enable Outlook-style replies. The inverse of
`my/disable-outlook-reply-style'."
(interactive)
(setq message-citation-line-function 'my/message-insert-outlook-citation-line)
(setq message-yank-prefix "")
(setq message-yank-empty-prefix "")
(setq message-yank-cited-prefix "")
(message "Enabled Outlook reply style"))
(defun my/disable-outlook-reply-style ()
"Disable Outlook-style replies. The inverse of
`my/enable-outlook-reply-style'."
(interactive)
(setq message-citation-line-function 'message-insert-citation-line)
(setq message-yank-prefix "> ")
(setq message-yank-empty-prefix ">")
(setq message-yank-cited-prefix ">")
(message "Disabled Outlook reply style"))
;; hotkeys for enabling/disabling outlook reply styles when in mu4e-view-mode
(add-hook 'mu4e-view-mode-hook (lambda () (local-set-key (kbd "C-c C-S-o") 'my/enable-outlook-reply-style)))
(add-hook 'mu4e-view-mode-hook (lambda () (local-set-key (kbd "C-c C-o") 'my/disable-outlook-reply-style)))
;; initially disable outlook plaintext reply style
(my/disable-outlook-reply-style)
@joefromct
Copy link

FYI, I had to swap out /dev/shm because (i just learned) my mac doesn't have a shared memory directory? Anyway, I'm using a temp dir from somewhere else.

only somewhat part time tinkering with this as other work has to be done today... but it seems to work well. complicated flow, it seems it has to be with mail these days. :/

Maybe this should be a repo rather than a gist? Let me know what you think.
Thanks again.

@tausen
Copy link
Author

tausen commented Jun 23, 2019

Ah, right - I've gotten too used to using /dev/shm/ on all my machines. The nicest solution would probably be a variable in lisp that could be passed along quitely to the Python script - I'll see if I can come up with something clever.

complicated flow, it seems it has to be with mail these days. :/

Yeah, I agree - there are already far too many tools and config files involved. I was hoping this could at least be a bit simpler and non-intrusive than the alternatives.

Maybe this should be a repo rather than a gist?

That's probably a good idea by now. I'll move it to a repo soon.

Thanks for the input!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment