]> git.neil.brown.name Git - edlib.git/blob - python/lib-compose-email.py
history: drop second string arg to attach-history
[edlib.git] / python / lib-compose-email.py
1 # -*- coding: utf-8 -*-
2 # Copyright Neil Brown ©2021-2023 <neil@brown.name>
3 # May be distributed under terms of GPLv2 - see file:COPYING
4 #
5 # edlib module for composing email.
6 # Message is composed in a text document with some markers that
7 # cannot be edited that contain extra information. These include
8 # - end-of-headers marker
9 # - attachment markers
10 # - sign/encrypt request (maybe later)
11 # - signature marker
12 #
13 # Markers are a single line, are drawn by a special handler, and
14 # can only be editted by popping up a dialog pane.  They have a vmark
15 # at each end with attributes attached to the first.  The text of the line
16 # starts "@#!compose" so it can be re-marked when a view is opened.
17 #
18 # All text is utf-8, and is encoded if necessary when the message is
19 # sent.  "sending" causes the whole message to be encoded into a temporary
20 # file, which is then given to a configured program (e.g. /usr/sbin/sendmail)
21 # as standard-input.
22 # Text is encoded as quoted-printable if needed, and attachments are
23 # combined in a multipart/mixed.
24 #
25 # Markers:
26 # - End of headers is marked with "@#!compose:headers\n"
27 # - An attachedment is marked with
28 #     @#!compose:attach type= filename= disposition= description=
29 #   The content of filename is url encoded
30 #
31
32 import edlib
33
34 import os
35 import subprocess
36 import email.utils
37 import email.message
38 import email.policy
39 import email.parser
40 import email.headerregistry
41 import tempfile
42 import mimetypes
43 import urllib
44 import re
45 from datetime import date
46
47 def read_status(p, key, focus, **a):
48     out, err = p.communicate()
49     focus.call("Message", "Email submission complete")
50     edlib.LOG("Email submission reported: " +
51               out.decode('utf-8','ignore') + err.decode('utf-8','ignore'))
52     return edlib.Efalse
53
54 class compose_email(edlib.Pane):
55     def __init__(self, focus):
56         edlib.Pane.__init__(self, focus)
57         self.view = focus.call("doc:add-view", self) - 1
58         self.complete_start = None
59         self.complete_menu = None
60         self.find_markers()
61         self.p = None
62         m, l = self.vmarks(self.view)
63         if not m:
64             self.insert_header_mark()
65         st = edlib.Mark(self)
66         m = focus.call("doc:point", ret='mark')
67         if m == st:
68             # At start of file - find a better place.
69             # If there is an empty header, go there, else after headers.
70             if not self.find_empty_header(m):
71                 self.to_body(m)
72
73         # track point movement so it can be moved out of a marker
74         self.point = focus.call("doc:point", ret='mark')
75         self.prev_point = None
76         self.have_prev = False
77         self.call("doc:request:mark:moving")
78
79     def add_headers_new(self, key, focus, **a):
80         "handle:compose-email:empty-headers"
81         fr = focus['email:from']
82         if fr:
83             nm = focus['email:name']
84             if nm:
85                 fr = "\"%s\" <%s>" %(nm, fr)
86         if fr:
87             self.check_header("From", fr)
88         self.check_header("To")
89         self.check_header("Cc")
90         self.check_header("Subject")
91         # mark the document as unmodified
92         self.call("doc:modified", -1)
93         m = edlib.Mark(self)
94         self.find_empty_header(m)
95         self.call("Move-to", m)
96         return 1
97
98     def copy_headers(self, key, focus, str, **a):
99         "handle:compose-email:copy-headers"
100         self.myaddr = None
101         # get date - it might be useful
102         focus.call("doc:multipart-0-list-headers", "date", self.copy_date)
103         # need to collect addresses even if I don't use them
104         # so that I can pick the right "from" address
105         self.addrlist = []
106         focus.call("doc:multipart-0-list-headers", "to", self.copy_addrs)
107         to_addrs = self.filter_cc(self.addrlist)
108         self.addrlist = []
109         focus.call("doc:multipart-0-list-headers", "cc", self.copy_addrs)
110         cc_addrs = self.filter_cc(self.addrlist)
111         self.addrlist = []
112         focus.call("doc:multipart-0-list-headers", "reply-to", self.copy_addrs)
113         if not self.addrlist:
114             focus.call("doc:multipart-0-list-headers", "from", self.copy_addrs)
115         from_addrs = self.filter_cc(self.addrlist)
116         if str != "reply-all":
117             to_addrs = None
118             cc_addrs = None
119         me = self.myaddr
120         if not me:
121             me = self['email:from']
122         if me:
123             nm = self['email:name']
124             if nm:
125                 me = "\"%s\" <%s>" %(nm, me)
126             self.check_header("From", me)
127         if self.addrlist:
128             n,a = self.addrlist[0]
129             self['reply-author'] = n if n else a
130
131         if str != "forward":
132             if from_addrs:
133                 self.add_addr_header("To", from_addrs)
134                 if not to_addrs:
135                     to_addrs = cc_addrs
136                 elif cc_addrs:
137                     to_addrs += cc_addrs
138                 self.add_addr_header("Cc", to_addrs)
139             else:
140                 self.add_addr_header("To", to_addrs)
141                 self.add_addr_header("Cc", cc_addrs)
142
143         self.pfx = "Re"
144         if str == "forward":
145             self.pfx = "Fwd"
146         self.check_header("To")
147         self.check_header("Cc")
148         focus.call("doc:multipart-0-list-headers", "subject", self.copy_subject)
149
150         self.addrlist = []
151         focus.call("doc:multipart-0-list-headers", "references", self.copy_addrs)
152         if not self.addrlist:
153             focus.call("doc:multipart-0-list-headers", "in-reply-to", self.copy_addrs)
154         l = len(self.addrlist)
155         focus.call("doc:multipart-0-list-headers", "message-id", self.copy_addrs)
156         if l < len(self.addrlist):
157             self.add_addr_header("In-reply-to", self.addrlist[l:], True)
158         self.add_addr_header("References", self.addrlist, True)
159         # mark the document as unmodified
160         self.call("doc:modified", -1)
161
162         m = edlib.Mark(self)
163         self.find_empty_header(m)
164         self.call("Move-to", m)
165         return 1
166
167     def copy_date(self, key, focus, str, **a):
168         self['date-str'] = str
169         d = email.utils.parsedate_tz(str)
170         if d:
171             self['date-seconds'] = "%d" % email.utils.mktime_tz(d)
172         return edlib.Efalse
173
174     def copy_addrs(self, key, focus, str, **a):
175         addr = email.utils.getaddresses([str])
176         self.addrlist.extend(addr)
177         return 1
178
179     def copy_subject(self, key, focus, str, **a):
180         m2, l = self.vmarks(self.view)
181         if m2:
182             subj = str.strip()
183             if not subj.lower().startswith(self.pfx.lower() + ':'):
184                 subj = self.pfx + ': ' + subj
185             self.call("doc:replace", m2, m2, ("Subject: "+ subj + "\n"))
186         return edlib.Efalse
187
188     def from_lists(self):
189         me = []
190         f = self['email:from']
191         if f:
192             me.append(f)
193         af = self['email:altfrom']
194         if af:
195             for a in af.strip().split("\n"):
196                 a = a.strip()
197                 if a not in me:
198                     me.append(a.strip())
199         dme = []
200         af = self['email:deprecated_from']
201         if af:
202             for a in af.strip().split("\n"):
203                 a = a.strip()
204                 if a not in dme:
205                     dme.append(a.strip())
206         return (me, dme)
207
208     def filter_cc(self, list):
209         # every unique address in list that isn't my address gets added
210         # to the new list
211         addrs = []
212         ret = []
213         me, dme = self.from_lists()
214         for name,addr in list:
215             if addr in me:
216                 if not self.myaddr:
217                     self.myaddr = addr
218             elif addr not in addrs and addr not in dme:
219                 addrs.append(addr)
220                 ret.append((name, addr))
221         return ret
222
223     def add_addr_header(self, hdr, addr, need_angle = False):
224         prefix = hdr + ": "
225         wrap = False
226         m2, l = self.vmarks(self.view)
227         if not m2:
228             return
229         if not addr:
230             return
231         # Note that we must call doc:replace on parent else
232         # we might be caught trying to insert a non-newline immediately
233         # before a marker.  We do eventuall insert a newline, so it is safe.
234         for n,a in addr:
235             if n:
236                 na = "\"%s\" <%s>" %(n, a)
237             elif need_angle:
238                 na = "<%s>" % a
239             else:
240                 na = a
241             if wrap:
242                 self.parent.call("doc:replace", m2, m2, prefix,
243                                  ",render:rfc822header-wrap=%d"%len(prefix))
244             else:
245                 self.parent.call("doc:replace", m2, m2, prefix)
246             wrap = True
247             prefix = ", "
248             self.parent.call("doc:replace", m2, m2, na)
249         if wrap:
250             # we wrote a header, so write a newline
251             self.parent.call("doc:replace", m2, m2, "\n")
252
253     def find_empty_header(self, m):
254         m2,l = self.vmarks(self.view)
255         try:
256             self.call("text-search", "^[!-9;-~]+\\h*:\\h*$", m, m2)
257             return True
258         except:
259             return False
260     def find_any_header(self, m):
261         m2, l = self.vmarks(self.view)
262         try:
263             self.call("text-search", "^[!-9;-~]+\\h*:\\h*", m, m2)
264             return True
265         except:
266             return False
267     def to_body(self, m):
268         m2, l = self.vmarks(self.view)
269         if m2:
270             m2 = m2.next()
271         if m2:
272             m.to_mark(m2)
273
274     def handle_quote_content(self, key, focus, str, **a):
275         "handle:compose-email:quote-content"
276         m = edlib.Mark(self)
277         self.to_body(m)
278         who = self['reply-author']
279         if not who:
280             who = "someone"
281         n = self['date-seconds']
282         if n:
283             d = date.fromtimestamp(int(n))
284             when = d.strftime("%a, %d %b %Y")
285         else:
286             when = 'a recent day'
287         q = "On %s, %s wrote:\n" % (when, who)
288         for l in str.split("\n"):
289             q += '> ' + l.strip('\r') + '\n'
290         self.call("doc:replace", m, m, q)
291         # mark the document as unmodified
292         self.call("doc:modified", -1)
293         return 1
294
295     def find_markers(self):
296         m = edlib.Mark(self)
297         while True:
298             try:
299                 r = self.parent.call("text-search", "^@#!compose:", m)
300             except edlib.commandfailed:
301                 r = 0
302             if r <= 0:
303                 break
304
305             self.parent.call("doc:EOL", -1, m)
306             m1 = edlib.Mark(self, self.view)
307             m1.to_mark(m)
308             self.parent.call("doc:set-attr", m,
309                              "markup:func", "compose:markup-header")
310             m2 = edlib.Mark(orig=m1)
311             self.parent.call("doc:EOL", 1, m2, 1)
312             s = self.parent.call("doc:get-str", m1, m2, ret='str')
313             s = s[11:].strip()
314             m1['compose-type'] = s.split(' ')[0]
315             m.to_mark(m2)
316
317     def insert_header_mark(self):
318         # insert header at first blank line, or end of file
319         m = edlib.Mark(self, self.view)
320         try:
321             self.call("text-search", "^$", m)
322         except:
323             self.call("doc:set-ref", 0, m)
324         m2 = edlib.Mark(orig=m)
325         m2.step(0)
326         self.parent.call("doc:replace", m2, m, "@#!compose:headers\n",
327                          "/markup:func=compose:markup-header/")
328         m2['compose-type'] = 'headers'
329
330     def check_header(self, header, content = ""):
331         # if header doesn't already exist, add it at end
332         m1 = edlib.Mark(self)
333         m2, l = self.vmarks(self.view)
334         try:
335             self.call("text-search", "^(?i:%s)\\h*:" % header, m1, m2)
336         except:
337             self.call("doc:replace", m2, m2, header + ': ' + content+'\n')
338
339     def markup_header(self, key, focus, num, mark, mark2, comm2, **a):
340         "handle:compose:markup-header"
341         # at least go to end of line
342         self.parent.call("doc:EOL", 1, mark)
343         m = self.vmark_at_or_before(self.view, mark)
344         if not m:
345             return None
346         type = m['compose-type']
347         if type == "headers":
348             markup =  "<fg:red>Headers above, content below"
349         else:
350             markup = "<fg:cyan-40>[section: %s]" % type
351             info = m['compose-info']
352             if info:
353                 markup += ' - ' + info
354         if num < 0:
355             # normal render - go past eol
356             self.parent.next(mark)
357             markup += "</>\n"
358         # return num==0 to display cursor at start or end, anything else
359         # should be suppressed.
360         return comm2("cb", focus, 0, markup)
361
362     def handle_clone(self, key, focus, **a):
363         "handle:Clone"
364         p = compose_email(focus)
365         self.clone_children(p)
366         return 1
367
368     def map_attr(self, key, focus, str, str2, mark, comm2, **a):
369         "handle:map-attr"
370         if not str or not mark or not comm2:
371             return edlib.Enoarg
372
373         if str == "render:compose-email-menu":
374             comm2("cb", focus, mark, "menu_here", 1, 250)
375             return 1
376
377         if str == "render:rfc822header-wrap":
378             comm2("attr:callback", focus, int(str2), mark, "wrap", 30)
379             return 1
380
381         # get previous mark and see if it is here
382         m = self.vmark_at_or_before(self.view, mark)
383         if not m and str == "start-of-line":
384             # start of a header line - set colour for tag and header, and wrap info
385             rv = self.call("text-match", "(\\h+|[!-9;-~]+\\h*:)", mark.dup())
386             if rv > 0:
387                 # make space or tag light blue, and body dark blue
388                 # If tag is unknown, make it grey
389                 rv2 = self.call("text-match",
390                                 "?i:(from|to|cc|subject|in-reply-to|references|date|message-id):",
391                                 mark.dup())
392                 if rv2 > 0:
393                     comm2("cb", focus, mark, rv-1, "fg:blue+30", 20)
394                 else:
395                     comm2("cb", focus, mark, rv-1, "fg:black+30", 20)
396                 comm2("cb", focus, mark, 0, "fg:blue-70,bold", 10)
397             else:
398                 # make whole line red
399                 comm2("cb", focus, mark, 0, "bg:red+50,fg:red-50", 20)
400             comm2("cb", focus, mark, 0, "wrap-tail: ,wrap-head:    ", 1)
401             return edlib.Efallthrough
402         if m and str == 'start-of-line':
403             # if line starts '>', give it some colour
404             if focus.following(mark) == '>':
405                 colours = ['red', 'red-60', 'green-60', 'magenta-60']
406                 m = mark.dup()
407                 cnt = 0
408                 c = focus.next(m)
409                 while c and c in ' >':
410                     if c == '>':
411                         cnt += 1
412                     c = focus.next(m)
413
414                 if cnt >= len(colours):
415                     cnt = len(colours)
416                 comm2("cb", focus, mark, 0, "fg:"+colours[cnt-1], 20)
417             return edlib.Efallthrough
418
419         return edlib.Efallthrough
420
421     def handle_replace(self, key, focus, mark, mark2, str, **a):
422         "handle:doc:replace"
423         self.complete_start = None
424         self.complete_end = None
425         if self.complete_menu:
426             self.complete_menu("Window:Close")
427         self.complete_menu = None
428         if not mark:
429             mark = focus.call("doc:point", ret='mark')
430         if not mark2:
431             mark2 = mark.dup()
432             self.parent.call("doc:set-ref", mark2, 0)
433         if not mark or not mark2:
434             # something weird...
435             return 1
436         if mark2 < mark:
437             mark, mark2 = mark2, mark
438
439         m = self.vmark_at_or_before(self.view, mark)
440         if m and m['compose-type']:
441             # must not edit a marker, but can insert a "\n" before
442             if mark == mark2 and mark == m and str and str[-1] == '\n':
443                 # inserting newline at start
444                 m.step(1)
445                 return edlib.Efallthrough
446             return 1
447         if mark2 == mark:
448             # not a delete, so should be safe as long as marks aren't before m
449             if m:
450                 m.step(0)
451             return edlib.Efallthrough
452         m2 = self.vmark_at_or_before(self.view, mark2)
453         if m2 and m2.prev() == m and mark2 == m2:
454             # deleting text just before a marker is OK as long as we will have
455             # a newline before the marker
456             if (str and str[-1] == '\n') or focus.prior(mark) == '\n':
457                 return edlib.Efallthrough
458         if m2 != m:
459             # not completely within a part, so fail
460             return 1
461         # deleting/replacing something after m/m2.
462         if m:
463             m.step(0)
464         return edlib.Efallthrough
465
466     def handle_moving(self, key, focus, mark, mark2, **a):
467         "handle:mark:moving"
468         if mark == self.point and not self.have_prev:
469             # We cannot dup because that triggers a recursive notification
470             #self.prev_point = mark.dup()
471             self.prev_point = self.vmark_at_or_before(self.view, mark)
472             self.have_prev = True
473             self.damaged(edlib.DAMAGED_VIEW)
474         return 1
475
476     def handle_review(self, key, focus, **a):
477         "handle:Refresh:view"
478         # if point is in a "header" move it to start or end
479         # opposite prev_point
480         if not self.have_prev:
481             return 1
482         m = self.vmark_at_or_before(self.view, self.point)
483         if m and m != self.point and m['compose-type']:
484             if not self.prev_point or self.prev_point < self.point:
485                 # moving toward end of file
486                 m = m.next()
487             if self.point != m:
488                 self.point.to_mark(m)
489         self.prev_point = None
490         self.have_prev = False
491         return 1
492
493     def handle_doc_get_attr(self, key, focus, mark, str, comm2, **a):
494         "handle:doc:get-attr"
495         if not mark or not str or not comm2 or not str.startswith("fill:"):
496             return edlib.Efallthrough
497         if str == "fill:repeating-prefix":
498             comm2("cb", focus, mark, ">", str)
499             return 1
500         m = self.vmark_at_or_before(self.view, mark)
501         if m:
502             return edlib.Efallthrough
503         # in headers, need a min-prefix and start-re for fill
504         if str == "fill:default-prefix":
505             comm2("cb", focus, mark, "  ", str)
506             return 1
507         if str == "fill:start-re" or str == "fill:end-re" :
508             comm2("cb", focus, mark, "^($|[^\\s])", str)
509             return 1
510
511     def try_address_complete(self, m):
512         this = self.this_header(m.dup())
513         if not this or this not in ["to","cc"]:
514             return False
515         st = m.dup()
516         word = self.prev_addr(st)
517         if not word:
518             return False
519         if ('@' in word and '.' in word and
520             ('>' in word or '<' not in word)):
521             # looks convincing - nothing to complete here
522             return False
523         if self.p:
524             # some other completion pending.
525             p = self.p
526             self.p = None
527             p.kill()
528             p.wait()
529         self.call("Message", "Trying to complete %s ..." % word)
530         self.complete_word = word
531         self.p = subprocess.Popen(["notmuch-addr", word],
532                                   stdout=subprocess.PIPE,
533                                   stderr=subprocess.PIPE)
534         self.complete_end = m.dup()
535         self.call("event:read", self.p.stdout.fileno(), self.get_complete)
536         return True
537
538     def get_complete(self, key, focus, **a):
539         if not self.p:
540             return edlib.Efalse
541         out,err = self.p.communicate()
542         self.p = None
543         if not out:
544             if err:
545                 self.call("Message", "Address expansion gave error: %s" %
546                           err.decode('utf-8','ignore'))
547                 return True
548             self.call("Message",
549                       "No completions found for address %s" % self.complete_word)
550             return edlib.Efalse
551         pt = focus.call("doc:point", ret='mark')
552         if not pt or not self.complete_end or pt != self.complete_end:
553             # point moved, do nothing
554             self.complete_end = None
555             return edlib.Efalse
556         self.complete_start = None
557         self.complete_end = None
558         if self.complete_menu:
559             self.complete_menu.call("Window:Close")
560         self.complete_menu = None
561         st = pt.dup()
562         word = self.prev_addr(st)
563         if not word:
564             return edlib.Efalse
565         complete_list = out.decode('utf-8','ignore').strip().split("\n")
566         if len(complete_list) > 1:
567             self.call("Message", "%d completions found for address %s"
568                       % (len(complete_list), word))
569             self.complete_start = st
570             st["render:compose-email-menu"] = "here"
571             mp = self.call("attach-menu", ret='pane')
572             for c in complete_list:
573                 mp.call("menu-add", c)
574             mp.call("doc:file", -1)
575             self.complete_menu = mp
576             self.add_notify(mp, "Notify:Close")
577             self.complete_end = pt.dup()
578             return edlib.Efalse
579         else:
580             self.call("Message", "only 1 completion found for address %s"
581                       % word)
582         self.parent.call("doc:replace", st, pt, complete_list[0])
583         return edlib.Efalse
584
585     def handle_close(self, key, focus, **a):
586         "handle:Notify:Close"
587         if focus == self.complete_menu:
588             self.complete_menu = None
589         return 1
590
591     def menu_done(self, key, focus, str1, **a):
592         "handle:menu-done"
593         if self.complete_start and str1:
594             self.call("doc:replace", str1, self.complete_end, self.complete_start)
595         self.complete_start = None
596         self.complete_end = None
597         self.complete_menu = None
598         return 1
599
600     def handle_draw(self, key, focus, str2, xy, **a):
601         "handle:Draw:text"
602         if not self.complete_menu or not str2 or ",menu_here" not in str2:
603             return edlib.Efallthrough
604
605         p = self.complete_menu.call("ThisPopup", ret='pane')
606         if p:
607             xy = p.parent.mapxy(focus, xy[0], focus.h)
608             p.x = xy[0]
609             p.y = xy[1]
610             if p.h > p.parent.h - p.y:
611                 # FIXME how do I avoid the border provided by lib-view
612                 p.h = p.parent.h - p.y
613         return edlib.Efallthrough
614
615     def try_cycle_from(self, m):
616         start = m.dup()
617         end = m.dup()
618         this = self.this_header(start, end)
619         if not this or this != "from":
620             return False
621         current = self.call("doc:get-str", start, end, ret='str')
622         name, addr = email.utils.parseaddr(current)
623         me, dme = self.from_lists()
624         if not me:
625             self.call("Message", "No From addresses declared")
626             return True
627         try:
628             i = me.index(addr) + 1
629         except ValueError:
630             i = 0
631         if i < 0 or i >= len(me):
632             addr = me[0]
633         else:
634             addr = me[i]
635         self.call("doc:replace", start, end,
636                   "%s <%s>" % (name, addr))
637         m.to_mark(end)
638         return True
639
640     def this_header(self, mark, end = None, downcase = True):
641         try:
642             l = self.call("text-search", "^[!-9;-~]+\\h*:", 0, 1, mark)
643             m1 = mark.dup()
644             while l > 2:
645                 l -= 1
646                 self.next(mark)
647             s = self.call("doc:get-str", m1, mark, ret='str')
648             self.next(mark)
649             while self.following(mark) == ' ':
650                 self.next(mark)
651             if downcase:
652                 ret = s.strip().lower()
653             else:
654                 ret = s.strip()
655         except edlib.commandfailed:
656             return None
657         if not end:
658             return ret
659         # Now find the body - mark is at the start
660         end.to_mark(mark)
661         mbody, l = self.vmarks(self.view)
662         try:
663             self.call("text-search", "^[!-9;-~]", end, mbody)
664         except:
665             end.to_mark(mbody)
666         self.call("doc:EOL", -1, 1, end)
667         return ret
668
669     def prev_addr(self, m):
670         # if previous char is not a space, collect everything
671         # back to , or : or \n and return 1
672         c = self.prev(m)
673         if not c or c in " \n":
674             return None
675         a = ''
676         while c and c not in ",:\n":
677             a = c + a
678             c = self.prev(m)
679         while c and c in ",: \n":
680             c = self.next(m)
681         if c:
682             self.prev(m)
683         return a.strip()
684
685     def handle_tab(self, key, focus, mark, **a):
686         "handle:K:Tab"
687         m2, l = self.vmarks(self.view)
688         if mark > m2:
689             return edlib.Efallthrough
690         # in headers, TAB does various things:
691         # In 'to' or 'cc' if the preceeding word looks like an
692         # incomplete address, then address completion is tried.
693         # Otherwise goes to next header if there is one, else to
694         # the body
695         if (not self.try_address_complete(mark) and
696             not self.try_cycle_from(mark) and
697             not self.find_any_header(mark)):
698             self.to_body(mark)
699         return 1
700     def handle_s_tab(self, key, focus, mark, **a):
701         "handle:K:S:Tab"
702         m2, l = self.vmarks(self.view)
703         if mark > m2:
704             # After header, S:Tab goes to last header
705             m = edlib.Mark(self)
706             mark.to_mark(m)
707             while self.find_any_header(m):
708                 mark.to_mark(m)
709             return 1
710         # in headers, go to previous header
711         m = edlib.Mark(self)
712         m2 = mark.dup()
713         while self.find_any_header(m) and m < m2:
714             mark.to_mark(m)
715         return 1
716
717     def handle_attach(self, key, focus, **a):
718         "handle:K:CC:C-A"
719         p = focus.call("PopupTile", "2", "", ret='pane')
720         if not p:
721             return edlib.Efail
722         p['prompt'] = "Attachment"
723         p['done-key'] = "compose-email:attach"
724         p.call('doc:set-name', "Attachment File")
725         p['pane-title'] = "Attachment File"
726         p = p.call("attach-history", "*Attachment History*",
727                    ret='pane')
728         p.call("attach-file-entry", "file")
729         return 1
730
731     def handle_do_attach(self, key, focus, str1, str2, **a):
732         "handle:compose-email:attach"
733         if not str1:
734             return 1
735         if str2:
736             type = str2
737         else:
738             (type, encoding) = mimetypes.guess_type(str1, False)
739         if not type:
740             type = "application/octet-stream"
741         f, l = self.vmarks(self.view)
742         m = edlib.Mark(orig=l)
743         self.call("doc:set-ref", 0, m)
744         m2 = edlib.Mark(orig=m)
745         # Make sure these 2 are the very last marks.
746         m2.step(1)
747         m.step(1)
748         self.parent.call("doc:replace", m2, m,
749                          "@#!compose:attach filename=%s type=%s\n" %
750                          (urllib.parse.quote(str1), type),
751                          "/markup:func=compose:markup-header/")
752         # m2 might have been reordered by doc:replace.
753         m2.step(1)
754         m2['compose-type'] = 'attach'
755         m2['compose-info'] = str1
756         return 1
757     def handle_commit(self, key, focus, **a):
758         "handle:Commit"
759         msg = email.message.EmailMessage()
760         m, l = self.vmarks(self.view)
761         if m:
762             # m is start of header marker, move to end.
763             m = m.next()
764         else:
765             return edlib.Efail
766         if m.next():
767             m2 = m.next()
768         else:
769             m2 = m.dup()
770             focus.call("doc:file", 1, m2)
771         txt = focus.call("doc:get-str", m, m2, ret='str')
772         msg.set_content(txt)
773         self.check_header("Date", email.utils.formatdate(localtime=True))
774         self.check_header("Message-id",
775                           email.utils.make_msgid(
776                               domain=focus['email:host-address']))
777         # discard empty headers
778         h = edlib.Mark(focus)
779         while self.find_empty_header(h):
780             focus.call("doc:EOL", -1, h)
781             h2 = h.dup()
782             focus.call("doc:EOL", 1, 1, h)
783             focus.call("doc:replace", h, h2)
784
785         # Now add all (non-empty) headers.
786         h = edlib.Mark(focus)
787         whoto = None
788         while self.find_any_header(h):
789             he = h.dup()
790             nm = self.this_header(h, he, downcase=False)
791             if not nm:
792                 break
793             bdy = focus.call("doc:get-str", h, he, ret='str')
794             # any newline, together with surrounding blanks, becomes a space
795             # email.message doesn't like split headers.
796             bdy = ' '.join(re.split(r"[ \t]*\n[ \t]*",bdy)).strip()
797             if bdy:
798                 msg[nm] = bdy
799             if nm.lower() == "to" and not whoto:
800                 try:
801                     n,a = email.utils.parseaddr(bdy)
802                     if n:
803                         whoto = n
804                     else:
805                         whoto = a
806                 except:
807                     pass
808
809         if not msg['from']:
810             self.call("Message", "No From: line in message - cannot send")
811             return edlib.Efail
812         if not msg['subject']:
813             self.call("Message", "No Subject: line in message - cannot send")
814             return edlib.Efail
815         if not msg['to'] and not msg['cc']:
816             self.call("Message", "No recipients (To: or Cc:) in message - cannot send")
817             return edlib.Efail
818
819         # Look for attachments
820         while m.next():
821             s = m.next()
822             m = s.next()
823             marker = focus.call("doc:get-str", s, m, ret='str')
824             if marker and marker.startswith("@#!compose:attach "):
825                 w = marker.strip().split(" ")
826                 maintype = "application"
827                 subtype = "octet-stream"
828                 desc = ""
829                 fn = None
830                 for attr in w[1:]:
831                     a = attr.split('=',1)
832                     if a and len(a) == 2 and a[0] == "filename":
833                         fn = urllib.parse.unquote(a[1])
834                         bn=os.path.basename(fn)
835                     if a[0] == "type":
836                         (maintype, subtype) = a[1].split("/", 1)
837                 if fn:
838                     try:
839                         with open(fn, 'rb') as fp:
840                             if maintype == "message":
841                                 p = email.parser.BytesParser()
842                                 eml = p.parse(fp)
843                                 msg.add_attachment(eml)
844                             else:
845                                 msg.add_attachment(fp.read(), filename=bn,
846                                                    maintype = maintype,
847                                                    subtype = subtype)
848                     except:
849                         self.call("Message", "Cannot read attachment %s" % fn)
850                         return edlib.Efail
851
852         s = msg.as_bytes()
853         tf = tempfile.TemporaryFile()
854         tf.write(s)
855         tf.seek(0)
856         sendmail = focus['email:sendmail']
857         if not sendmail:
858             sendmail = "/sbin/sendmail -i"
859         try:
860             p = subprocess.Popen(sendmail, shell=True,
861                                  stdin = tf.fileno(),
862                                  stdout = subprocess.PIPE,
863                                  stderr = subprocess.PIPE)
864         except:
865             p = None
866         if not p:
867             focus.call("Message", "Failed to run sendmail command")
868             edlib.LOG("%s failed", sendmail)
869             return 1
870         if not whoto:
871             whoto = "someone"
872         focus.call("doc:set-name", "*Sent message to %s*" % whoto)
873         focus.call("doc:set:email-sent", "yes")
874         focus.call("doc:modified", -1)
875         fn = self['filename']
876         if fn and fn.startswith('/'):
877             try:
878                 os.unlink(fn)
879             except FileNotFoundError:
880                 pass
881
882         root = self.call("RootPane", ret='pane')
883         if root:
884             root.call("event:read", p.stdout.fileno(),
885                       lambda key, **a: read_status(p, key, **a))
886             focus.call("Message", "Queueing message to %s." % whoto)
887             focus.call("Window:bury")
888             return 1
889
890         # Cannot find pane to report status on, so do it sync
891         out, err = p.communicate()
892         if out:
893             edlib.LOG("Email submission says:", out.decode('utf-8','ignore'))
894         if err:
895             focus.call("Message", "Email submission gives err: " +
896                        err.decode('utf-8','ignore'))
897         focus.call("Message", "Email message to %s queued." % whoto)
898         focus.call("Window:bury")
899
900         return 1
901
902     def handle_spell(self, key, focus, mark, **a):
903         "handle:Spell:NextWord"
904         m2, l = self.vmarks(self.view)
905         if not mark or not m2 or not m2.next() or mark > m2:
906             return edlib.Efallthrough
907         found_end = False
908         while not found_end:
909             h = self.this_header(mark.dup())
910             if h and h.lower() in ["subject"]:
911                 return edlib.Efallthrough
912             found_end = not self.find_any_header(mark)
913         mark.to_mark(m2.next())
914         return edlib.Efallthrough
915
916     def handle_template(self, key, focus, **a):
917         "handle:K:CC-t"
918         # If document hasn't been modified, ask for a template name
919         # and us it.
920         # The external command "template" will list known templates, which
921         # can be given as an arg to produce the template.
922         mod = focus['doc-modified']
923         if mod and mod == 'yes':
924             focus.call("Message", "Compose template requires unchanged document.")
925             return 1
926         p = subprocess.Popen("template", shell=True,
927                              stdout = subprocess.PIPE, stderr = subprocess.DEVNULL)
928         out, err = p.communicate()
929         if not out:
930             focus.call("Message", "No known compose templates.")
931             return 1
932
933         doc = focus.call("doc:from-text", "*Choose Template*", out.decode(),
934                          ret='pane')
935         doc.call("doc:set:autoclose", 1)
936         pop = focus.call("PopupTile", "M1", ret='pane')
937         p = doc.call("doc:attach-view", pop, -1, ret='pane')
938         p['done-key'] = "Compose:Template"
939         p.call("attach-render-complete")
940
941         return 1
942
943     def handle_got_template(self, key, focus, str1, **a):
944         "handle:Compose:Template"
945         if not str1:
946             return
947         p = subprocess.Popen(["template", str1], stdout=subprocess.PIPE,
948                              stderr = subprocess.DEVNULL)
949         out, err = p.communicate()
950         if not out:
951             focus.call("Message", "No template provided.")
952             return
953         out = out.decode()
954         sep = out.find('\n\n')
955         if sep > 0:
956             hdr = out[:sep+1]
957             body = out[sep+2:]
958         else:
959             body = out
960         if hdr:
961             m = edlib.Mark(self)
962             f, l = self.vmarks(self.view)
963             focus.call("doc:replace", hdr, m, f)
964         if body:
965             m = edlib.Mark(self)
966             self.to_body(m)
967             focus.call("doc:replace", m, body)
968         focus.call("compose-email:empty-headers")
969
970         return 1
971
972 def compose_mode_attach(key, focus, comm2, **a):
973     focus['fill-width'] = '72'
974     p = focus.call("attach-textfill", ret='pane')
975     if p:
976         focus = p
977     p = focus.call("attach-autospell", ret='pane')
978     if p:
979         focus = p
980     p = compose_email(focus)
981     if comm2:
982         comm2("cb", p)
983     return 1
984
985 edlib.editor.call("global-set-command", "attach-compose-email", compose_mode_attach)