mournmail 0.1.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Textbringer
4
2
  Face.define :seen, foreground: "blue"
5
3
  Face.define :deleted, foreground: "green"
@@ -0,0 +1,71 @@
1
+ require "mail"
2
+
3
+ module Mournmail
4
+ module MailEncodedWordPatch
5
+ private
6
+
7
+ def fold(prepend = 0) # :nodoc:
8
+ charset = normalized_encoding
9
+ decoded_string = decoded.to_s
10
+ if charset != "UTF-8" ||
11
+ decoded_string.ascii_only? ||
12
+ !decoded_string.respond_to?(:encoding) ||
13
+ decoded_string.encoding != Encoding::UTF_8 ||
14
+ Regexp.new('\p{Han}|\p{Hiragana}|\p{Katakana}') !~ decoded_string
15
+ # Use Q encoding
16
+ return super(prepend)
17
+ end
18
+ words = decoded_string.split(/[ \t]/)
19
+ folded_lines = []
20
+ b_encoding_extra_size = "=?#{charset}?B??=".bytesize
21
+ while !words.empty?
22
+ limit = 78 - prepend
23
+ line = +""
24
+ fold_line = false
25
+ while !fold_line && !words.empty?
26
+ word = words.first
27
+ s = (line.empty? ? "" : " ").dup
28
+ if word.ascii_only?
29
+ s << word
30
+ break if !line.empty? && line.bytesize + s.bytesize > limit
31
+ words.shift
32
+ if prepend + line.bytesize + s.bytesize > 998
33
+ words.unshift(s.slice!(998 - prepend .. -1))
34
+ fold_line = true
35
+ end
36
+ else
37
+ words.shift
38
+ encoded_text = base64_encode(word)
39
+ min_size = line.bytesize + s.bytesize + b_encoding_extra_size
40
+ new_size = min_size + encoded_text.bytesize
41
+ if new_size > limit
42
+ n = ((limit - min_size) * 3.0 / 4.0).floor
43
+ if n <= 0
44
+ words.unshift(word) if !line.empty?
45
+ break
46
+ end
47
+ truncated = word.byteslice(0, n).scrub("")
48
+ rest = word.byteslice(truncated.bytesize,
49
+ word.bytesize - truncated.bytesize)
50
+ words.unshift(rest)
51
+ encoded_text = base64_encode(truncated)
52
+ fold_line = true
53
+ end
54
+ encoded_word = "=?#{charset}?B?#{encoded_text}?="
55
+ s << encoded_word
56
+ end
57
+ line << s
58
+ end
59
+ folded_lines << line
60
+ prepend = 1 # Space will be prepended
61
+ end
62
+ folded_lines
63
+ end
64
+
65
+ def base64_encode(word)
66
+ Mail::Encodings::Base64.encode(word).gsub(/[\r\n]/, "")
67
+ end
68
+ end
69
+ end
70
+
71
+ Mail::UnstructuredField.prepend(Mournmail::MailEncodedWordPatch)
@@ -1,4 +1,5 @@
1
- # frozen_string_literal: true
1
+ require "uri"
2
+ require "mime/types"
2
3
 
3
4
  using Mournmail::MessageRendering
4
5
 
@@ -7,18 +8,23 @@ module Mournmail
7
8
  MESSAGE_MODE_MAP = Keymap.new
8
9
  MESSAGE_MODE_MAP.define_key("\C-m", :message_open_link_or_part_command)
9
10
  MESSAGE_MODE_MAP.define_key("s", :message_save_part_command)
11
+ MESSAGE_MODE_MAP.define_key("\t", :message_next_link_or_part_command)
10
12
 
11
13
  # See http://nihongo.jp/support/mail_guide/dev_guide.txt
12
- URI_REGEXP = /(https?|ftp):\/\/[^  \t\n>)"]*[^]  \t\n>.,:)"]+/
14
+ MAILTO_REGEXP = URI.regexp("mailto")
15
+ URI_REGEXP = /(https?|ftp):\/\/[^  \t\n>)"]*[^\]  \t\n>.,:)"]+|#{MAILTO_REGEXP}/
16
+ MIME_REGEXP = /^\[(([0-9.]+) [A-Za-z._\-]+\/[A-Za-z._\-]+.*|PGP\/MIME .*)\]$/
17
+ URI_OR_MIME_REGEXP = /#{URI_REGEXP}|#{MIME_REGEXP}/
13
18
 
14
19
  define_syntax :field_name, /^[A-Za-z\-]+: /
15
20
  define_syntax :quotation, /^>.*/
16
- define_syntax :mime_part, /^\[([0-9.]+) [A-Za-z._\-]+\/[A-Za-z._\-]+.*\]$/
21
+ define_syntax :mime_part, MIME_REGEXP
17
22
  define_syntax :link, URI_REGEXP
18
23
 
19
24
  def initialize(buffer)
20
25
  super(buffer)
21
26
  buffer.keymap = MESSAGE_MODE_MAP
27
+ @attached_file = nil
22
28
  end
23
29
 
24
30
  define_local_command(:message_open_link_or_part,
@@ -45,6 +51,18 @@ module Mournmail
45
51
  end
46
52
  end
47
53
 
54
+ define_local_command(:message_next_link_or_part,
55
+ doc: "Go to the next link or MIME part.") do
56
+ if @buffer.looking_at?(URI_OR_MIME_REGEXP)
57
+ @buffer.forward_char
58
+ end
59
+ if @buffer.re_search_forward(URI_OR_MIME_REGEXP, raise_error: false)
60
+ goto_char(@buffer.match_beginning(0))
61
+ else
62
+ @buffer.beginning_of_buffer
63
+ end
64
+ end
65
+
48
66
  private
49
67
 
50
68
  def current_part
@@ -53,7 +71,7 @@ module Mournmail
53
71
  if @buffer.looking_at?(/\[([0-9.]+) .*\]/)
54
72
  index = match_string(1)
55
73
  indices = index.split(".").map(&:to_i)
56
- Mournmail.current_mail.dig_part(*indices)
74
+ @buffer[:mournmail_mail].dig_part(*indices)
57
75
  else
58
76
  nil
59
77
  end
@@ -62,8 +80,8 @@ module Mournmail
62
80
 
63
81
  def part_file_name(part)
64
82
  file_name =
65
- part["content-disposition"]&.parameters&.[]("filename") ||
66
- part["content-type"]&.parameters&.[]("name") ||
83
+ (part["content-disposition"]&.parameters&.[]("filename") rescue nil) ||
84
+ (part["content-type"]&.parameters&.[]("name") rescue nil) ||
67
85
  part_default_file_name(part)
68
86
  decoded_file_name = Mail::Encodings.decode_encode(file_name, :decode)
69
87
  if /\A([A-Za-z0-9_\-]+)'(?:[A-Za-z0-9_\-])*'(.*)/ =~ decoded_file_name
@@ -74,7 +92,12 @@ module Mournmail
74
92
  end
75
93
 
76
94
  def part_default_file_name(part)
77
- base_name = part.cid.gsub(/[^A-Za-z0-9_\-]/, "_")
95
+ base_name =
96
+ begin
97
+ part.cid.gsub(/[^A-Za-z0-9_\-]/, "_")
98
+ rescue NoMethodError
99
+ "mournmail"
100
+ end
78
101
  ext = part_extension(part)
79
102
  if ext
80
103
  base_name + "." + ext
@@ -111,22 +134,44 @@ module Mournmail
111
134
  else
112
135
  file_name = "mournmail"
113
136
  end
114
- f = Tempfile.open(file_name)
115
- f.write(part.decoded)
116
- f.close
117
- if ext == "txt"
118
- find_file(f.path)
137
+ @attached_file = Tempfile.open(file_name, binmode: true)
138
+ s = part.decoded
139
+ if part.content_type == "text/html"
140
+ s = s.sub(/<meta http-equiv="content-type".*?>/i, "")
141
+ elsif part.charset
142
+ s = s.encode(part.charset)
143
+ end
144
+ @attached_file.write(s)
145
+ @attached_file.close
146
+ if part.main_type == "text" && part.sub_type != "html"
147
+ find_file(@attached_file.path)
119
148
  else
120
149
  background do
121
- system(*CONFIG[:mournmail_file_open_comamnd], f.path,
150
+ system(*CONFIG[:mournmail_file_open_comamnd], @attached_file.path,
122
151
  out: File::NULL, err: File::NULL)
123
152
  end
124
153
  end
125
154
  end
126
155
 
127
156
  def open_uri(uri)
128
- system(*CONFIG[:mournmail_link_open_comamnd], uri,
129
- out: File::NULL, err: File::NULL)
157
+ case uri
158
+ when /\Amailto:/
159
+ u = URI.parse(uri)
160
+ if u.headers.assoc("subject")
161
+ re = /^To:\s*\nSubject:\s*\n/
162
+ else
163
+ re = /^To:\s*\n/
164
+ end
165
+ Commands.mail
166
+ beginning_of_buffer
167
+ re_search_forward(re)
168
+ replace_match("")
169
+ insert u.to_mailtext.sub(/\n\n\z/, "")
170
+ end_of_buffer
171
+ else
172
+ system(*CONFIG[:mournmail_link_open_comamnd], uri,
173
+ out: File::NULL, err: File::NULL)
174
+ end
130
175
  end
131
176
  end
132
177
  end
@@ -1,7 +1,5 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "mail"
4
- require "mail-iso-2022-jp"
2
+ require "html2text"
5
3
 
6
4
  module Mournmail
7
5
  module MessageRendering
@@ -10,9 +8,9 @@ module Mournmail
10
8
  render_header + "\n" + render_body(indices)
11
9
  end
12
10
 
13
- def render_header
14
- CONFIG[:mournmail_display_header_fields].map { |name|
15
- val = self[name]
11
+ def render_header(fields = CONFIG[:mournmail_display_header_fields])
12
+ fields.map { |name|
13
+ val = self[name]&.to_s&.gsub(/\t/, " ")
16
14
  val ? "#{name}: #{val}\n" : ""
17
15
  }.join
18
16
  end
@@ -20,28 +18,68 @@ module Mournmail
20
18
  def render_body(indices = [])
21
19
  if HAVE_MAIL_GPG && encrypted?
22
20
  mail = decrypt(verify: true)
23
- return mail.render_body(indices)
21
+ if mail.signatures.empty?
22
+ sig = ""
23
+ else
24
+ sig = "[PGP/MIME signature]\n" +
25
+ signature_of(mail)
26
+ end
27
+ return "[PGP/MIME encrypted message]\n" + mail.render(indices) + sig
24
28
  end
25
29
  if multipart?
26
30
  parts.each_with_index.map { |part, i|
27
- part.render([*indices, i])
31
+ no_content = sub_type == "alternative" && i > 0
32
+ part.render([*indices, i], no_content)
28
33
  }.join
29
- else
30
- s = body.decoded
31
- if /\Autf-8\z/i =~ charset
32
- s.force_encoding(Encoding::UTF_8).scrub("?")
34
+ elsif main_type.nil? || main_type == "text"
35
+ s = Mournmail.to_utf8(body.decoded, charset)
36
+ if sub_type == "html"
37
+ "[0 text/html]\n" + Html2Text.convert(s)
33
38
  else
34
- s.encode(Encoding::UTF_8, charset, replace: "?")
35
- end.gsub(/\r\n/, "\n")
39
+ s
40
+ end
41
+ else
42
+ type = Mail::Encodings.decode_encode(self["content-type"].to_s,
43
+ :decode) rescue
44
+ "broken/type; error=\"#{$!} (#{$!.class})\""
45
+ "[0 #{type}]\n"
36
46
  end + pgp_signature
37
47
  end
38
48
 
49
+ def render_text(indices = [])
50
+ if HAVE_MAIL_GPG && encrypted?
51
+ mail = decrypt(verify: true)
52
+ return mail.render_text(indices)
53
+ end
54
+ if multipart?
55
+ parts.each_with_index.map { |part, i|
56
+ if sub_type == "alternative" && i > 0
57
+ ""
58
+ else
59
+ part.render_text([*indices, i])
60
+ end
61
+ }.join("\n")
62
+ elsif main_type.nil? || main_type == "text"
63
+ s = Mournmail.to_utf8(body.decoded, charset)
64
+ if sub_type == "html"
65
+ Html2Text.convert(s)
66
+ else
67
+ s
68
+ end
69
+ else
70
+ ""
71
+ end
72
+ end
73
+
39
74
  def dig_part(i, *rest_indices)
40
75
  if HAVE_MAIL_GPG && encrypted?
41
76
  mail = decrypt(verify: true)
42
77
  return mail.dig_part(i, *rest_indices)
43
78
  end
44
- part = parts[i]
79
+ if i == 0
80
+ return self
81
+ end
82
+ part = parts[i - 1]
45
83
  if rest_indices.empty?
46
84
  part
47
85
  else
@@ -54,31 +92,71 @@ module Mournmail
54
92
  def pgp_signature
55
93
  if HAVE_MAIL_GPG && signed?
56
94
  verified = verify
57
- validity = verified.signature_valid? ? "Good" : "Bad"
58
- from = verified.signatures.map { |sig|
59
- sig.from rescue sig.fingerprint
60
- }.join(", ")
61
- "#{validity} signature from #{from}\n"
95
+ signature_of(verified)
62
96
  else
63
97
  ""
64
98
  end
65
99
  end
100
+
101
+ def signature_of(m)
102
+ validity = m.signature_valid? ? "Good" : "Bad"
103
+ from = m.signatures.map { |sig|
104
+ sig.from rescue sig.fingerprint
105
+ }.join(", ")
106
+ s = "#{validity} signature from #{from}"
107
+ message(s)
108
+ s + "\n"
109
+ end
66
110
  end
67
111
 
68
112
  refine ::Mail::Part do
69
- def render(indices)
70
- index = indices.join(".")
113
+ def render(indices, no_content = false)
114
+ index = indices.map { |i| i + 1 }.join(".")
71
115
  type = Mail::Encodings.decode_encode(self["content-type"].to_s,
72
- :decode)
73
- "[#{index} #{type}]\n" + render_content(indices)
116
+ :decode) rescue
117
+ "broken/type; error=\"#{$!} (#{$!.class})\""
118
+ "[#{index} #{type}]\n" +
119
+ render_content(indices, no_content)
120
+ end
121
+
122
+ def render_text(indices)
123
+ if multipart?
124
+ parts.each_with_index.map { |part, i|
125
+ if sub_type == "alternative" && i > 0
126
+ ""
127
+ else
128
+ part.render_text([*indices, i])
129
+ end
130
+ }.join("\n")
131
+ else
132
+ if main_type == "message" && sub_type == "rfc822"
133
+ mail = Mail.new(body.raw_source)
134
+ mail.render_header(CONFIG[:mournmail_quote_header_fields]) +
135
+ "\n" + mail.render_text(indices)
136
+ elsif attachment?
137
+ ""
138
+ else
139
+ if main_type == "text"
140
+ if sub_type == "html"
141
+ Html2Text.convert(decoded).sub(/(?<!\n)\z/, "\n")
142
+ else
143
+ decoded.sub(/(?<!\n)\z/, "\n").gsub(/\r\n/, "\n")
144
+ end
145
+ else
146
+ ""
147
+ end
148
+ end
149
+ end
150
+ rescue => e
151
+ ""
74
152
  end
75
153
 
76
154
  def dig_part(i, *rest_indices)
77
155
  if main_type == "message" && sub_type == "rfc822"
78
- mail = Mail.new(body.raw_source)
156
+ mail = Mail.new(body.to_s)
79
157
  mail.dig_part(i, *rest_indices)
80
158
  else
81
- part = parts[i]
159
+ part = parts[i - 1]
82
160
  if rest_indices.empty?
83
161
  part
84
162
  else
@@ -89,23 +167,33 @@ module Mournmail
89
167
 
90
168
  private
91
169
 
92
- def render_content(indices)
170
+ def render_content(indices, no_content)
93
171
  if multipart?
94
172
  parts.each_with_index.map { |part, i|
95
- part.render([*indices, i])
173
+ part.render([*indices, i],
174
+ no_content || sub_type == "alternative" && i > 0)
96
175
  }.join
97
- elsif main_type == "message" && sub_type == "rfc822"
98
- mail = Mail.new(body.raw_source)
99
- mail.render(indices)
100
- elsif self["content-disposition"]&.disposition_type == "attachment"
101
- ""
102
176
  else
103
- if main_type == "text" && sub_type == "plain"
104
- decoded.sub(/(?<!\n)\z/, "\n").gsub(/\r\n/, "\n")
105
- else
177
+ return "" if no_content
178
+ if main_type == "message" && sub_type == "rfc822"
179
+ mail = Mail.new(body.raw_source)
180
+ mail.render(indices)
181
+ elsif attachment?
106
182
  ""
183
+ else
184
+ if main_type == "text"
185
+ if sub_type == "html"
186
+ Html2Text.convert(decoded).sub(/(?<!\n)\z/, "\n")
187
+ else
188
+ decoded.sub(/(?<!\n)\z/, "\n").gsub(/\r\n/, "\n")
189
+ end
190
+ else
191
+ ""
192
+ end
107
193
  end
108
194
  end
195
+ rescue => e
196
+ "Broken part: #{e} (#{e.class})"
109
197
  end
110
198
  end
111
199
  end
@@ -0,0 +1,143 @@
1
+ using Mournmail::MessageRendering
2
+
3
+ module Mournmail
4
+ class SearchResultMode < Mournmail::SummaryMode
5
+ SEARCH_RESULT_MODE_MAP = Keymap.new
6
+ SEARCH_RESULT_MODE_MAP.define_key(" ", :summary_read_command)
7
+ SEARCH_RESULT_MODE_MAP.define_key(:backspace, :summary_scroll_down_command)
8
+ SEARCH_RESULT_MODE_MAP.define_key("\C-h", :summary_scroll_down_command)
9
+ SEARCH_RESULT_MODE_MAP.define_key("\C-?", :summary_scroll_down_command)
10
+ SEARCH_RESULT_MODE_MAP.define_key("w", :summary_write_command)
11
+ SEARCH_RESULT_MODE_MAP.define_key("a", :summary_reply_command)
12
+ SEARCH_RESULT_MODE_MAP.define_key("A", :summary_reply_command)
13
+ SEARCH_RESULT_MODE_MAP.define_key("f", :summary_forward_command)
14
+ SEARCH_RESULT_MODE_MAP.define_key("v", :summary_view_source_command)
15
+ SEARCH_RESULT_MODE_MAP.define_key("q", :search_result_close_command)
16
+ SEARCH_RESULT_MODE_MAP.define_key("k", :previous_line)
17
+ SEARCH_RESULT_MODE_MAP.define_key("j", :next_line)
18
+ SEARCH_RESULT_MODE_MAP.define_key("<", :previous_page_command)
19
+ SEARCH_RESULT_MODE_MAP.define_key(">", :next_page_command)
20
+ SEARCH_RESULT_MODE_MAP.define_key("/", :summary_search_command)
21
+ SEARCH_RESULT_MODE_MAP.define_key("t", :summary_show_thread_command)
22
+ SEARCH_RESULT_MODE_MAP.define_key("@", :summary_change_account_command)
23
+
24
+ def initialize(buffer)
25
+ super(buffer)
26
+ buffer.keymap = SEARCH_RESULT_MODE_MAP
27
+ end
28
+
29
+ define_local_command(:summary_read, doc: "Read a mail.") do
30
+ num = scroll_up_or_current_number
31
+ return if num.nil?
32
+ Mournmail.background do
33
+ message = @buffer[:messages][num]
34
+ if message.nil? || message._key.nil?
35
+ raise EditorError, "No message found"
36
+ end
37
+ mail = Mail.new(Mournmail.read_mail_cache(message._key))
38
+ foreground do
39
+ show_message(mail)
40
+ @buffer[:message_number] = num
41
+ end
42
+ end
43
+ end
44
+
45
+ define_local_command(:summary_scroll_down,
46
+ doc: "Scroll down the current message.") do
47
+ num = @buffer.current_line
48
+ if num == @buffer[:message_number]
49
+ window = Mournmail.message_window
50
+ if window.buffer.name == "*message*"
51
+ old_window = Window.current
52
+ begin
53
+ Window.current = window
54
+ scroll_down
55
+ return
56
+ ensure
57
+ Window.current = old_window
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ define_local_command(:search_result_close,
64
+ doc: "Close the search result.") do
65
+ if @buffer.name == "*thread*"
66
+ buf = Buffer["*search result*"] || "*summary*"
67
+ else
68
+ buf = "*summary*"
69
+ end
70
+ kill_buffer(@buffer)
71
+ switch_to_buffer(buf)
72
+ end
73
+
74
+ define_local_command(:previous_page,
75
+ doc: "Show the previous page.") do
76
+ messages = @buffer[:messages]
77
+ page = messages.current_page - 1
78
+ if page < 1
79
+ raise EditorError, "No more page."
80
+ end
81
+ summary_search(@buffer[:query], page)
82
+ end
83
+
84
+ define_local_command(:next_page,
85
+ doc: "Show the next page.") do
86
+ messages = @buffer[:messages]
87
+ page = messages.current_page + 1
88
+ if page > messages.n_pages
89
+ raise EditorError, "No more page."
90
+ end
91
+ summary_search(@buffer[:query], page)
92
+ end
93
+
94
+ private
95
+
96
+ def scroll_up_or_current_number
97
+ begin
98
+ num = @buffer.current_line
99
+ if num == @buffer[:message_number]
100
+ window = Mournmail.message_window
101
+ if window.buffer.name == "*message*"
102
+ old_window = Window.current
103
+ begin
104
+ Window.current = window
105
+ scroll_up
106
+ return nil
107
+ ensure
108
+ Window.current = old_window
109
+ end
110
+ end
111
+ end
112
+ num
113
+ rescue RangeError # may be raised by scroll_up
114
+ next_message
115
+ retry
116
+ end
117
+ end
118
+
119
+ def read_current_mail
120
+ message = @buffer[:messages][@buffer.current_line]
121
+ if message.nil?
122
+ raise EditorError, "No message found"
123
+ end
124
+ [Mail.new(Mournmail.read_mail_cache(message._key)), false]
125
+ end
126
+
127
+ def next_message
128
+ @buffer.end_of_line
129
+ if @buffer.end_of_buffer?
130
+ raise EditorError, "No more mail"
131
+ end
132
+ @buffer.forward_line
133
+ end
134
+
135
+ def current_message
136
+ message = @buffer[:messages][@buffer.current_line]
137
+ if message.nil?
138
+ raise EditorError, "No message found"
139
+ end
140
+ message
141
+ end
142
+ end
143
+ end