3 from __future__ import absolute_import
6 from urllib.parse import quote_plus
7 from urllib.parse import unquote_plus
9 from urllib import quote_plus
10 from urllib import unquote_plus
12 from datetime import datetime
13 from mailbox import MaildirMessage
22 from notmuch2 import Database
23 from jinja2 import Environment, FileSystemLoader # FIXME to PackageLoader
24 from jinja2 import Markup
26 import bjoern # from https://github.com/jonashaag/bjoern/
31 # Configuration options
32 safe_tags = bleach.sanitizer.ALLOWED_TAGS + \
33 [u'div', u'span', u'p', u'br', u'table', u'tr', u'td', u'th']
34 linkify_plaintext = True # delays page load by about 0.02s of 0.20s budget
35 show_thread_nav = True # delays page load by about 0.04s of 0.20s budget
37 prefix = os.environ.get('NMWEB_PREFIX', "http://localhost:8080")
38 webprefix = os.environ.get('NMWEB_STATIC', prefix + "/static")
39 cachedir = os.environ.get('NMWEB_CACHE', "static/cache") # special for webpy server; changeable if using your own
40 cachepath = os.environ.get('NMWEB_CACHE_PATH', cachedir) # location of static cache in the local filesystem
42 if 'NMWEB_DEBUG' in os.environ:
43 web.config.debug = True
45 web.config.debug = False
47 # End of config options
49 env = Environment(autoescape=True,
50 loader=FileSystemLoader('templates'))
54 '/search/(.*)', 'search',
58 def urlencode_filter(s):
59 if type(s) == 'Markup':
64 env.filters['url'] = urlencode_filter
68 web.header('Content-type', 'text/html')
69 base = env.get_template('base.html')
70 template = env.get_template('index.html')
73 return template.render(tags=tags,
74 title="Notmuch webmail",
81 if web.input(terms=None).terms:
83 terms = web.input().terms
84 terms = unquote_plus (terms)
85 if web.input(afters=None).afters:
86 afters = web.input(afters=None).afters[:-3]
89 if web.input(befores=None).befores:
90 befores = web.input(befores=None).befores
92 befores = '4294967296' # 2^32
94 if int(afters) > 0 or int(befores) < 4294967296:
96 terms += ' date:@%s..@%s' % (int(afters), int(befores))
100 raise web.seeother('/search/%s' % quote_plus(terms.encode('utf8')))
101 web.header('Content-type', 'text/html')
103 ts = db.threads(query=terms, sort=Database.SORT.NEWEST_FIRST)
104 template = env.get_template('search.html')
105 return template.generate(terms=terms,
111 def format_time_range(start, end):
112 if end-start < (60*60*24):
113 time = datetime.fromtimestamp(start).strftime('%Y %b %d %H:%M')
115 start = datetime.fromtimestamp(start).strftime("%Y %b %d")
116 end = datetime.fromtimestamp(end).strftime("%Y %b %d")
117 time = "%s through %s" % (start, end)
119 env.globals['format_time_range'] = format_time_range
121 def mailto_addrs(msg,header_name):
123 hdr = msg.header(header_name)
127 frm = email.utils.getaddresses([hdr])
128 return ','.join(['<a href="mailto:%s">%s</a> ' % ((l, p) if p else (l, l)) for (p, l) in frm])
129 env.globals['mailto_addrs'] = mailto_addrs
132 lnk = quote_plus(msg.messageid.encode('utf8'))
134 subj = html.escape(msg.header('Subject'))
137 out = '<a href="%s/show/%s">%s</a>' % (prefix, lnk, subj)
139 env.globals['link_msg'] = link_msg
144 red = 'color:black; font-style:normal'
146 red = 'color:red; font-style:italic'
147 frm = mailto_addrs(msg,'From')
149 tags = ", ".join(msg.tags)
150 rs = show_msgs(msg.replies())
151 r += '<li><span style="%s">%s—%s</span> [%s] %s</li>' % (red, frm, lnk, tags, rs)
154 env.globals['show_msgs'] = show_msgs
156 # As email.message.walk, but showing close tags as well
159 if self.is_multipart():
160 for subpart in self.get_payload():
161 for subsubpart in mywalk(subpart):
167 web.header('Content-type', 'text/html')
172 raise web.notfound("No such message id.")
173 template = env.get_template('show.html')
174 # FIXME add reply-all link with email.urils.getaddresses
175 # FIXME add forward link using mailto with body parameter?
176 return template.render(m=m,
178 title=m.header('Subject'),
183 if not show_thread_nav: return
185 thread = next(db.threads('thread:'+m.threadid))
194 else: # found message, but not on this loop
198 if prv: yield "<li>Previous message (by thread): %s</li>" % link_msg(prv)
199 if nxt: yield "<li>Next message (by thread): %s</li>" % link_msg(nxt)
200 yield "</ul><h3>Thread:</h3>"
201 # FIXME show now takes three queries instead of 1;
202 # can we yield the message body while computing the thread shape?
203 thread = next(db.threads('thread:'+m.threadid))
204 yield show_msgs(thread.toplevel())
206 env.globals['thread_nav'] = thread_nav
208 def format_message(nm_msg, mid):
209 fn = list(nm_msg.filenames())[0]
210 msg = MaildirMessage(open(fn))
211 return format_message_walk(msg, mid)
213 def decodeAnyway(txt, charset='ascii'):
215 out = txt.decode(charset)
218 out = txt.decode('utf-8')
219 except UnicodeDecodeError:
220 out = txt.decode('latin1')
223 def require_protocol_prefix(attrs, new=False):
226 link_text = attrs[u'_text']
227 if link_text.startswith(('http:', 'https:', 'mailto:', 'git:', 'id:')):
231 # Bleach doesn't even try to linkify id:... text, so no point invoking this yet
232 def modify_id_links(attrs, new=False):
233 if attrs[(None, u'href')].startswith(u'id:'):
234 attrs[(None, u'href')] = prefix + "/show/" + attrs[(None, u'href')][3:]
237 def css_part_id(content_type, parts=[]):
238 c = content_type.replace('/', '-')
239 out = "-".join(parts + [c])
242 def format_message_walk(msg, mid):
246 for part in mywalk(msg):
247 if part == 'close-div':
250 elif part.get_content_maintype() == 'multipart':
251 yield '<div class="multipart-%s" id="%s">' % \
252 (part.get_content_subtype(), css_part_id(part.get_content_type(), parts))
253 parts.append(part.get_content_subtype())
254 if part.get_content_subtype() == 'alternative':
256 for subpart in part.get_payload():
257 yield ('<li><a href="#%s">%s</a></li>' %
258 (css_part_id(subpart.get_content_type(), parts),
259 subpart.get_content_type()))
261 elif part.get_content_type() == 'message/rfc822':
262 # FIXME extract subject, date, to/cc/from into a separate template and use it here
263 yield '<div class="message-rfc822">'
264 elif part.get_content_maintype() == 'text':
265 if part.get_content_subtype() == 'plain':
266 yield '<div id="%s">' % css_part_id(part.get_content_type(), parts)
268 out = part.get_payload(decode=True)
269 out = decodeAnyway(out, part.get_content_charset('ascii'))
270 out = html.escape(out)
271 out = out.encode('ascii', 'xmlcharrefreplace').decode('ascii')
272 if linkify_plaintext: out = bleach.linkify(out, callbacks=[require_protocol_prefix])
275 elif part.get_content_subtype() == 'html':
276 yield '<div id="%s">' % css_part_id(part.get_content_type(), parts)
277 unb64 = part.get_payload(decode=True)
278 decoded = decodeAnyway(unb64, part.get_content_charset('ascii'))
279 cid_refd += find_cids(decoded)
280 part.set_payload(bleach.clean(replace_cids(decoded, mid), tags=safe_tags).
281 encode(part.get_content_charset('ascii'), 'xmlcharrefreplace'))
282 (filename, cid) = link_to_cached_file(part, mid, counter)
284 yield '<iframe class="embedded-html" src="%s"></iframe>' % \
285 os.path.join(prefix, cachedir, mid, filename)
288 yield '<div id="%s">' % css_part_id(part.get_content_type(), parts)
289 (filename, cid) = link_to_cached_file(part, mid, counter)
291 yield '<a href="%s">%s (%s)</a>' % (os.path.join(prefix,
296 part.get_content_type())
298 elif part.get_content_maintype() == 'image':
299 (filename, cid) = link_to_cached_file(part, mid, counter)
300 if cid not in cid_refd:
302 yield '<img src="%s" alt="%s">' % (os.path.join(prefix,
308 (filename, cid) = link_to_cached_file(part, mid, counter)
310 yield '<a href="%s">%s (%s)</a>' % (os.path.join(prefix,
315 part.get_content_type())
316 env.globals['format_message'] = format_message
318 def replace_cids(body, mid):
319 return body.replace('cid:', os.path.join(prefix, cachedir, mid)+'/')
322 return re.findall(r'cid:([^ "\'>]*)', body)
324 def link_to_cached_file(part, mid, counter):
325 filename = part.get_filename()
327 ext = mimetypes.guess_extension(part.get_content_type())
330 filename = 'part-%03d%s' % (counter, ext)
332 os.makedirs(os.path.join(cachepath, mid))
335 fn = os.path.join(cachepath, mid, filename) # FIXME escape mid, filename
337 if part.get_content_maintype() == 'text':
338 data = part.get_payload(decode=True)
339 data = decodeAnyway(data, part.get_content_charset('ascii')).encode('utf-8')
342 data = part.get_payload(decode=True)
344 data = part.get_payload(decode=False)
348 if 'Content-ID' in part:
349 cid = part['Content-ID']
350 if cid[0] == '<' and cid[-1] == '>': cid = cid[1:-1]
351 cid_fn = os.path.join(cachepath, mid, cid) # FIXME escape mid, cid
357 return (filename, cid)
359 return (filename, None)
361 if __name__ == '__main__':
362 app = web.application(urls, globals())
364 bjoern.run(app.wsgifunc(), "127.0.0.1", 8080)