mournmail 0.1.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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