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,24 +1,47 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "time"
4
2
  require "fileutils"
5
3
  require "monitor"
6
4
 
7
5
  module Mournmail
8
6
  class Summary
9
- attr_reader :items, :last_uid
7
+ attr_reader :mailbox, :items, :last_uid
8
+ attr_accessor :uidvalidity
10
9
 
11
10
  include MonitorMixin
12
11
 
12
+ LOCK_OPERATIONS = Hash.new(:unknown_mode)
13
+ LOCK_OPERATIONS[:shared] = File::LOCK_SH
14
+ LOCK_OPERATIONS[:exclusive] = File::LOCK_EX
15
+
16
+ def self.lock_cache(mailbox, mode)
17
+ File.open(Summary.cache_lock_path(mailbox), "w", 0600) do |f|
18
+ f.flock(LOCK_OPERATIONS[mode])
19
+ yield
20
+ end
21
+ end
22
+
13
23
  def self.cache_path(mailbox)
14
24
  File.join(Mournmail.mailbox_cache_path(mailbox), ".summary")
15
25
  end
16
26
 
27
+ def self.cache_lock_path(mailbox)
28
+ cache_path(mailbox) + ".lock"
29
+ end
30
+
31
+ def self.cache_tmp_path(mailbox)
32
+ cache_path(mailbox) + ".tmp"
33
+ end
34
+
35
+ def self.cache_old_path(mailbox)
36
+ cache_path(mailbox) + ".old"
37
+ end
38
+
17
39
  def self.load(mailbox)
18
- File.open(cache_path(mailbox)) { |f|
19
- f.flock(File::LOCK_SH)
20
- Marshal.load(f)
21
- }
40
+ lock_cache(mailbox, :shared) do
41
+ File.open(cache_path(mailbox)) do |f|
42
+ Marshal.load(f)
43
+ end
44
+ end
22
45
  end
23
46
 
24
47
  def self.load_or_new(mailbox)
@@ -34,6 +57,7 @@ module Mournmail
34
57
  @message_id_table = {}
35
58
  @uid_table = {}
36
59
  @last_uid = nil
60
+ @uidvalidity = nil
37
61
  end
38
62
 
39
63
  DUMPABLE_VARIABLES = [
@@ -41,7 +65,8 @@ module Mournmail
41
65
  :@items,
42
66
  :@message_id_table,
43
67
  :@uid_table,
44
- :@last_uid
68
+ :@last_uid,
69
+ :@uidvalidity
45
70
  ]
46
71
 
47
72
  def marshal_dump
@@ -59,12 +84,14 @@ module Mournmail
59
84
 
60
85
  def add_item(item, message_id, in_reply_to)
61
86
  synchronize do
62
- parent = @message_id_table[in_reply_to]
63
- if parent
64
- parent.add_reply(item)
65
- else
66
- @items.push(item)
67
- end
87
+ # Disable threads
88
+ # parent = @message_id_table[in_reply_to]
89
+ # if parent
90
+ # parent.add_reply(item)
91
+ # else
92
+ # @items.push(item)
93
+ # end
94
+ @items.push(item)
68
95
  if message_id
69
96
  @message_id_table[message_id] = item
70
97
  end
@@ -92,20 +119,62 @@ module Mournmail
92
119
  end
93
120
  end
94
121
 
122
+ def uids
123
+ synchronize do
124
+ @uid_table.keys
125
+ end
126
+ end
127
+
128
+ def read_mail(uid)
129
+ synchronize do
130
+ item = @uid_table[uid]
131
+ if item.cache_id
132
+ File.open(Mournmail.mail_cache_path(item.cache_id)) do |f|
133
+ [Mournmail.parse_mail(f.read), false]
134
+ end
135
+ else
136
+ Mournmail.imap_connect do |imap|
137
+ imap.select(@mailbox)
138
+ data = imap.uid_fetch(uid, "BODY[]")
139
+ if data.nil? || data.empty?
140
+ raise EditorError, "No such mail: #{uid}"
141
+ end
142
+ s = data[0].attr["BODY[]"]
143
+ mail = Mournmail.parse_mail(s)
144
+ spam_mailbox = Mournmail.account_config[:spam_mailbox]
145
+ if @mailbox != Net::IMAP.encode_utf7(spam_mailbox)
146
+ item.cache_id = Mournmail.write_mail_cache(s)
147
+ Mournmail.index_mail(item.cache_id, mail)
148
+ end
149
+ [mail, true]
150
+ end
151
+ end
152
+ end
153
+ end
154
+
95
155
  def save
96
156
  synchronize do
97
157
  path = Summary.cache_path(@mailbox)
98
158
  FileUtils.mkdir_p(File.dirname(path))
99
- File.open(Summary.cache_path(@mailbox), "w", 0600) do |f|
100
- f.flock(File::LOCK_EX)
101
- Marshal.dump(self, f)
159
+ Summary.lock_cache(@mailbox, :exclusive) do
160
+ cache_path = Summary.cache_path(@mailbox)
161
+ tmp_path = Summary.cache_tmp_path(@mailbox)
162
+ old_path = Summary.cache_old_path(@mailbox)
163
+ File.open(tmp_path, "w", 0600) do |f|
164
+ Marshal.dump(self, f)
165
+ end
166
+ begin
167
+ File.rename(cache_path, old_path)
168
+ rescue Errno::ENOENT
169
+ end
170
+ File.rename(tmp_path, cache_path)
102
171
  end
103
172
  end
104
173
  end
105
174
 
106
175
  def to_s
107
176
  synchronize do
108
- items.each_with_object(String.new) do |item, s|
177
+ items.each_with_object(+"") do |item, s|
109
178
  s << item.to_s
110
179
  end
111
180
  end
@@ -115,6 +184,7 @@ module Mournmail
115
184
  class SummaryItem
116
185
  attr_reader :uid, :date, :from, :subject, :flags
117
186
  attr_reader :replies
187
+ attr_accessor :cache_id
118
188
 
119
189
  def initialize(uid, date, from, subject, flags)
120
190
  @uid = uid
@@ -124,6 +194,7 @@ module Mournmail
124
194
  @flags = flags
125
195
  @line = nil
126
196
  @replies = []
197
+ @cache_id = nil
127
198
  end
128
199
 
129
200
  def add_reply(reply)
@@ -177,7 +248,7 @@ module Mournmail
177
248
 
178
249
  def format_line(limit = 78, from_limit = 16, level = 0)
179
250
  space = " " * (level < 8 ? level : 8)
180
- s = String.new
251
+ s = +""
181
252
  s << format("%6d %s%s %s[ %s ] ",
182
253
  @uid, format_flags(@flags), format_date(@date), space,
183
254
  ljust(format_from(@from), from_limit))
@@ -188,7 +259,7 @@ module Mournmail
188
259
 
189
260
  def ljust(s, n)
190
261
  width = 0
191
- str = String.new
262
+ str = +""
192
263
  s.each_char do |c|
193
264
  w = Buffer.display_width(c)
194
265
  width += w
@@ -203,14 +274,14 @@ module Mournmail
203
274
  end
204
275
 
205
276
  def format_flags(flags)
206
- if flags.include?(:Deleted)
277
+ if !flags.include?(:Seen)
278
+ "u"
279
+ elsif flags.include?(:Deleted)
207
280
  "d"
208
281
  elsif flags.include?(:Flagged)
209
282
  "$"
210
283
  elsif flags.include?(:Answered)
211
284
  "a"
212
- elsif !flags.include?(:Seen)
213
- "u"
214
285
  else
215
286
  " "
216
287
  end
@@ -1,7 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
- require "tempfile"
4
-
5
1
  using Mournmail::MessageRendering
6
2
 
7
3
  module Mournmail
@@ -20,18 +16,34 @@ module Mournmail
20
16
  SUMMARY_MODE_MAP.define_key("u", :summary_toggle_seen_command)
21
17
  SUMMARY_MODE_MAP.define_key("$", :summary_toggle_flagged_command)
22
18
  SUMMARY_MODE_MAP.define_key("d", :summary_toggle_deleted_command)
23
- SUMMARY_MODE_MAP.define_key("x", :summary_expunge_command)
19
+ SUMMARY_MODE_MAP.define_key("x", :summary_toggle_mark_command)
20
+ SUMMARY_MODE_MAP.define_key("*a", :summary_mark_all_command)
21
+ SUMMARY_MODE_MAP.define_key("*n", :summary_unmark_all_command)
22
+ SUMMARY_MODE_MAP.define_key("*r", :summary_mark_read_command)
23
+ SUMMARY_MODE_MAP.define_key("*u", :summary_mark_unread_command)
24
+ SUMMARY_MODE_MAP.define_key("*s", :summary_mark_flagged_command)
25
+ SUMMARY_MODE_MAP.define_key("*t", :summary_mark_unflagged_command)
26
+ SUMMARY_MODE_MAP.define_key("y", :summary_archive_command)
27
+ SUMMARY_MODE_MAP.define_key("o", :summary_refile_command)
28
+ SUMMARY_MODE_MAP.define_key("!", :summary_refile_spam_command)
29
+ SUMMARY_MODE_MAP.define_key("p", :summary_prefetch_command)
30
+ SUMMARY_MODE_MAP.define_key("X", :summary_expunge_command)
24
31
  SUMMARY_MODE_MAP.define_key("v", :summary_view_source_command)
32
+ SUMMARY_MODE_MAP.define_key("M", :summary_merge_partial_command)
25
33
  SUMMARY_MODE_MAP.define_key("q", :mournmail_quit)
26
34
  SUMMARY_MODE_MAP.define_key("k", :previous_line)
27
35
  SUMMARY_MODE_MAP.define_key("j", :next_line)
28
36
  SUMMARY_MODE_MAP.define_key("m", :mournmail_visit_mailbox)
37
+ SUMMARY_MODE_MAP.define_key("S", :mournmail_visit_spam_mailbox)
38
+ SUMMARY_MODE_MAP.define_key("/", :summary_search_command)
39
+ SUMMARY_MODE_MAP.define_key("t", :summary_show_thread_command)
40
+ SUMMARY_MODE_MAP.define_key("@", :summary_change_account_command)
29
41
 
30
- define_syntax :seen, /^ *\d+ .*/
31
- define_syntax :unseen, /^ *\d+ u.*/
32
- define_syntax :flagged, /^ *\d+ \$.*/
33
- define_syntax :deleted, /^ *\d+ d.*/
34
- define_syntax :answered, /^ *\d+ a.*/
42
+ define_syntax :seen, /^ *\d+[ *] .*/
43
+ define_syntax :unseen, /^ *\d+[ *]u.*/
44
+ define_syntax :flagged, /^ *\d+[ *]\$.*/
45
+ define_syntax :deleted, /^ *\d+[ *]d.*/
46
+ define_syntax :answered, /^ *\d+[ *]a.*/
35
47
 
36
48
  def initialize(buffer)
37
49
  super(buffer)
@@ -41,23 +53,12 @@ module Mournmail
41
53
  define_local_command(:summary_read, doc: "Read a mail.") do
42
54
  uid = scroll_up_or_next_uid
43
55
  return if uid.nil?
56
+ summary = Mournmail.current_summary
44
57
  Mournmail.background do
45
- mailbox = Mournmail.current_mailbox
46
- s, fetched = Mournmail.read_mail(mailbox, uid)
47
- mail = Mail.new(s)
48
- message = mail.render
49
- next_tick do
50
- message_buffer = Buffer.find_or_new("*message*",
51
- undo_limit: 0, read_only: true)
52
- message_buffer.apply_mode(Mournmail::MessageMode)
53
- message_buffer.read_only_edit do
54
- message_buffer.clear
55
- message_buffer.insert(message)
56
- message_buffer.beginning_of_buffer
57
- end
58
- window = Mournmail.message_window
59
- window.buffer = message_buffer
60
- mark_as_seen(uid, !fetched)
58
+ mail, fetched = summary.read_mail(uid)
59
+ foreground do
60
+ show_message(mail)
61
+ mark_as_seen(uid, false)
61
62
  Mournmail.current_uid = uid
62
63
  Mournmail.current_mail = mail
63
64
  end
@@ -97,12 +98,10 @@ module Mournmail
97
98
  define_local_command(:summary_reply,
98
99
  doc: "Reply to the current message.") do
99
100
  |reply_all = current_prefix_arg|
100
- uid = selected_uid
101
101
  Mournmail.background do
102
- mailbox = Mournmail.current_mailbox
103
- mail = Mail.new(Mournmail.read_mail(mailbox, uid)[0])
104
- body = mail.render_body
105
- next_tick do
102
+ mail = read_current_mail[0]
103
+ body = mail.render_text
104
+ foreground do
106
105
  Window.current = Mournmail.message_window
107
106
  Commands.mail(run_hooks: false)
108
107
  if reply_all
@@ -117,13 +116,20 @@ module Mournmail
117
116
  insert(mail.reply_to&.join(", ") || mail.from&.join(", "))
118
117
  end
119
118
  re_search_forward(/^Subject: /)
120
- subject = mail["subject"].to_s
119
+ subject = mail["subject"].to_s.gsub(/\t/, " ")
121
120
  if /\Are:/i !~ subject
122
121
  insert("Re: ")
123
122
  end
124
123
  insert(subject)
125
- if mail['message-id']
126
- insert("\nIn-Reply-To: #{mail['message-id']}")
124
+ references = mail.references ?
125
+ Array(mail.references) : Array(mail.in_reply_to)
126
+ if mail.message_id
127
+ insert("\nIn-Reply-To: <#{mail.message_id}>")
128
+ references.push(mail.message_id)
129
+ end
130
+ if !references.empty?
131
+ refs = references.map { |id| "<#{id}>" }.join(" ")
132
+ insert("\nReferences: " + refs)
127
133
  end
128
134
  end_of_buffer
129
135
  push_mark
@@ -133,6 +139,7 @@ module Mournmail
133
139
  On #{mail['date']}
134
140
  #{mail['from']} wrote:
135
141
  EOF
142
+ Mournmail.insert_signature
136
143
  exchange_point_and_mark
137
144
  run_hooks(:mournmail_draft_setup_hook)
138
145
  end
@@ -141,14 +148,12 @@ module Mournmail
141
148
 
142
149
  define_local_command(:summary_forward,
143
150
  doc: "Forward the current message.") do
144
- uid = selected_uid
145
- summary = Mournmail.current_summary
146
- item = summary[uid]
151
+ message = current_message
147
152
  Window.current = Mournmail.message_window
148
153
  Commands.mail
149
154
  re_search_forward(/^Subject: /)
150
- insert("Forward: " + Mournmail.decode_eword(item.subject))
151
- insert("\nAttached-Message: #{Mournmail.current_mailbox}/#{uid}")
155
+ insert("Forward: " + message.subject)
156
+ insert("\nAttached-Message: #{message._key}")
152
157
  re_search_backward(/^To: /)
153
158
  end_of_line
154
159
  end
@@ -165,27 +170,81 @@ module Mournmail
165
170
 
166
171
  define_local_command(:summary_toggle_deleted,
167
172
  doc: <<~EOD) do
168
- Toggle Deleted. Type `x` to expunge deleted messages.
173
+ Toggle Deleted. Type `X` to expunge deleted messages.
169
174
  EOD
170
175
  toggle_flag(selected_uid, :Deleted)
171
176
  end
172
177
 
178
+ define_local_command(:summary_toggle_mark, doc: "Toggle mark.") do
179
+ @buffer.read_only_edit do
180
+ @buffer.save_excursion do
181
+ @buffer.beginning_of_line
182
+ if @buffer.looking_at?(/( *\d+)([ *])/)
183
+ uid = @buffer.match_string(1)
184
+ old_mark = @buffer.match_string(2)
185
+ new_mark = old_mark == "*" ? " " : "*"
186
+ @buffer.replace_match(uid + new_mark)
187
+ end
188
+ end
189
+ end
190
+ end
191
+
192
+ define_local_command(:summary_mark_all, doc: "Mark all mails.") do
193
+ gsub_buffer(/^( *\d+) /, '\\1*')
194
+ end
195
+
196
+ define_local_command(:summary_unmark_all, doc: "Unmark all mails.") do
197
+ gsub_buffer(/^( *\d+)\*/, '\\1 ')
198
+ end
199
+
200
+ define_local_command(:summary_mark_read, doc: "Mark read mails.") do
201
+ gsub_buffer(/^( *\d+) ([^u])/, '\\1*\\2')
202
+ end
203
+
204
+ define_local_command(:summary_mark_unread, doc: "Mark unread mails.") do
205
+ gsub_buffer(/^( *\d+) u/, '\\1*u')
206
+ end
207
+
208
+ define_local_command(:summary_mark_flagged, doc: "Mark flagged mails.") do
209
+ gsub_buffer(/^( *\d+) \$/, '\\1*$')
210
+ end
211
+
212
+ define_local_command(:summary_mark_unflagged,
213
+ doc: "Mark unflagged mails.") do
214
+ gsub_buffer(/^( *\d+) ([^$])/, '\\1*\\2')
215
+ end
216
+
173
217
  define_local_command(:summary_expunge,
174
218
  doc: <<~EOD) do
175
219
  Expunge deleted messages.
176
220
  EOD
177
221
  buffer = Buffer.current
222
+ mailbox = Mournmail.current_mailbox
223
+ summary = Mournmail.current_summary
178
224
  Mournmail.background do
179
225
  Mournmail.imap_connect do |imap|
180
226
  imap.expunge
181
227
  end
182
- summary = Mournmail.current_summary
183
228
  summary.delete_item_if do |item|
184
- item.flags.include?(:Deleted)
229
+ if item.flags.include?(:Deleted)
230
+ if item.cache_id
231
+ begin
232
+ File.unlink(Mournmail.mail_cache_path(item.cache_id))
233
+ rescue Errno::ENOENT
234
+ end
235
+ begin
236
+ Groonga["Messages"].delete(item.cache_id)
237
+ rescue Groonga::InvalidArgument
238
+ end
239
+ true
240
+ end
241
+ else
242
+ false
243
+ end
185
244
  end
186
245
  summary_text = summary.to_s
187
246
  summary.save
188
- next_tick do
247
+ foreground do
189
248
  buffer.read_only_edit do
190
249
  buffer.clear
191
250
  buffer.insert(summary_text)
@@ -199,15 +258,14 @@ module Mournmail
199
258
  doc: "View source of a mail.") do
200
259
  uid = selected_uid
201
260
  Mournmail.background do
202
- mailbox = Mournmail.current_mailbox
203
- source, = Mournmail.read_mail(mailbox, uid)
204
- next_tick do
261
+ mail, = read_current_mail
262
+ foreground do
205
263
  source_buffer = Buffer.find_or_new("*message-source*",
206
264
  file_encoding: "ascii-8bit",
207
265
  undo_limit: 0, read_only: true)
208
266
  source_buffer.read_only_edit do
209
267
  source_buffer.clear
210
- source_buffer.insert(source.gsub(/\r\n/, "\n"))
268
+ source_buffer.insert(mail.raw_source.gsub(/\r\n/, "\n"))
211
269
  source_buffer.file_format = :dos
212
270
  source_buffer.beginning_of_buffer
213
271
  end
@@ -217,6 +275,207 @@ module Mournmail
217
275
  end
218
276
  end
219
277
 
278
+ define_local_command(:summary_merge_partial,
279
+ doc: "Merge marked message/partial.") do
280
+ uids = []
281
+ @buffer.save_excursion do
282
+ @buffer.beginning_of_buffer
283
+ while @buffer.re_search_forward(/^( *\d+)\*/, raise_error: false)
284
+ uid = @buffer.match_string(0).to_i
285
+ # @buffer.replace_match(@buffer.match_string(0) + " ")
286
+ uids.push(uid)
287
+ end
288
+ end
289
+ summary = Mournmail.current_summary
290
+ Mournmail.background do
291
+ id = nil
292
+ total = nil
293
+ mails = uids.map { |uid|
294
+ summary.read_mail(uid)[0]
295
+ }.select { |mail|
296
+ mail.main_type == "message" &&
297
+ mail.sub_type == "partial" #&&
298
+ (id ||= mail["Content-Type"].parameters["id"]) ==
299
+ mail["Content-Type"].parameters["id"] &&
300
+ (total ||= mail["Content-Type"].parameters["total"]&.to_i)
301
+ }.sort_by { |mail|
302
+ mail["Content-Type"].parameters["number"].to_i
303
+ }
304
+ if mails.length != total
305
+ raise EditorError, "No enough messages (#{mails.length} of #{total})"
306
+ end
307
+ s = mails.map { |mail| mail.body.decoded }.join
308
+ mail = Mail.new(s)
309
+ foreground do
310
+ show_message(mail)
311
+ Mournmail.current_uid = nil
312
+ Mournmail.current_mail = mail
313
+ end
314
+ end
315
+ end
316
+
317
+ define_local_command(:summary_archive,
318
+ doc: "Archive marked mails.") do
319
+ archive_mailbox_format =
320
+ Mournmail.account_config[:archive_mailbox_format]
321
+ if archive_mailbox_format.nil?
322
+ raise EditorError, "No archive_mailbox_format in the current account"
323
+ end
324
+ uids = marked_uids
325
+ summary = Mournmail.current_summary
326
+ now = Time.now
327
+ if archive_mailbox_format == false
328
+ mailboxes = { nil => uids }
329
+ else
330
+ mailboxes = uids.map { |uid| summary[uid] }.group_by { |item|
331
+ t = Time.parse(item.date) rescue now
332
+ t.strftime(archive_mailbox_format)
333
+ }.transform_values { |items|
334
+ items.map(&:uid)
335
+ }
336
+ end
337
+ source_mailbox = Mournmail.current_mailbox
338
+ if mailboxes.key?(source_mailbox)
339
+ raise EditorError, "Can't archive mails in archive mailboxes"
340
+ end
341
+ Mournmail.background do
342
+ Mournmail.imap_connect do |imap|
343
+ mailboxes.each do |mailbox, item_uids|
344
+ if mailbox && !imap.list("", mailbox)
345
+ imap.create(mailbox)
346
+ end
347
+ refile_mails(imap, source_mailbox, item_uids, mailbox)
348
+ end
349
+ imap.expunge
350
+ delete_from_summary(summary, uids, "Archived messages")
351
+ end
352
+ end
353
+ end
354
+
355
+ define_local_command(:summary_refile,
356
+ doc: "Refile marked mails.") do
357
+ |mailbox = Mournmail.read_mailbox_name("Refile mails: ")|
358
+ uids = marked_uids
359
+ summary = Mournmail.current_summary
360
+ source_mailbox = Mournmail.current_mailbox
361
+ if source_mailbox == mailbox
362
+ raise EditorError, "Can't refile to the same mailbox"
363
+ end
364
+ Mournmail.background do
365
+ Mournmail.imap_connect do |imap|
366
+ unless imap.list("", mailbox)
367
+ if foreground! { yes_or_no?("#{mailbox} doesn't exist; Create?") }
368
+ imap.create(mailbox)
369
+ else
370
+ next
371
+ end
372
+ end
373
+ refile_mails(imap, source_mailbox, uids, mailbox)
374
+ delete_from_summary(summary, uids, "Refiled messages")
375
+ end
376
+ end
377
+ end
378
+
379
+ define_local_command(:summary_refile_spam,
380
+ doc: "Refile marked mails as spam.") do
381
+ mailbox = Mournmail.account_config[:spam_mailbox]
382
+ if mailbox.nil?
383
+ raise EditorError, "spam_mailbox is not specified"
384
+ end
385
+ summary_refile(Net::IMAP.encode_utf7(mailbox))
386
+ end
387
+
388
+ define_local_command(:summary_prefetch,
389
+ doc: "Prefetch mails.") do
390
+ summary = Mournmail.current_summary
391
+ mailbox = Mournmail.current_mailbox
392
+ spam_mailbox = Mournmail.account_config[:spam_mailbox]
393
+ if mailbox == Net::IMAP.encode_utf7(spam_mailbox)
394
+ raise EditorError, "Can't prefetch spam"
395
+ end
396
+ target_uids = @buffer.to_s.scan(/^ *\d+/).map { |s|
397
+ s.to_i
398
+ }.select { |uid|
399
+ summary[uid].cache_id.nil?
400
+ }
401
+ Mournmail.background do
402
+ Mournmail.imap_connect do |imap|
403
+ imap.select(mailbox)
404
+ count = 0
405
+ begin
406
+ target_uids.each_slice(20) do |uids|
407
+ data = imap.uid_fetch(uids, "BODY[]")
408
+ data&.each do |i|
409
+ uid = i.attr["UID"]
410
+ s = i.attr["BODY[]"]
411
+ if s
412
+ cache_id = Mournmail.write_mail_cache(s)
413
+ Mournmail.index_mail(cache_id, Mail.new(s))
414
+ summary[uid].cache_id = cache_id
415
+ end
416
+ end
417
+ count += uids.size
418
+ progress = (count.to_f * 100 / target_uids.size).round
419
+ foreground do
420
+ message("Prefetching mails... #{progress}%", log: false)
421
+ end
422
+ end
423
+ ensure
424
+ summary.save
425
+ end
426
+ end
427
+ foreground do
428
+ message("Done")
429
+ end
430
+ end
431
+ end
432
+
433
+ define_local_command(:summary_search, doc: "Search mails.") do
434
+ |query = read_from_minibuffer("Search mail: ",
435
+ initial_value: @buffer[:query]),
436
+ page = 1|
437
+ Mournmail.background do
438
+ messages = Groonga["Messages"].select { |record|
439
+ record.match(query) { |match_record|
440
+ match_record.subject | match_record.body
441
+ }
442
+ }.paginate([["date", :desc]], page: page, size: 100)
443
+ foreground do
444
+ show_search_result(messages, query: query)
445
+ message("Searched (#{messages.current_page}/#{messages.n_pages})")
446
+ end
447
+ end
448
+ end
449
+
450
+ define_local_command(:summary_show_thread,
451
+ doc: "Show the thread of the current mail.") do
452
+ Mournmail.background do
453
+ message = current_message
454
+ messages = Groonga["Messages"].select { |m|
455
+ m.thread_id == message.thread_id
456
+ }.sort([["date", :asc]])
457
+ foreground do
458
+ show_search_result(messages, buffer_name: "*thread*")
459
+ i = messages.find_index { |m| m._key == message._key }
460
+ Buffer.current.goto_line(i + 1)
461
+ end
462
+ end
463
+ end
464
+
465
+ define_local_command(:summary_change_account,
466
+ doc: "Change the current account.") do
467
+ |account = Mournmail.read_account_name("Change account: ")|
468
+ unless CONFIG[:mournmail_accounts].key?(account)
469
+ raise EditorError, "No such account: #{account}"
470
+ end
471
+ if Mournmail.background_thread
472
+ raise EditorError, "Background thread is running"
473
+ end
474
+ mournmail_quit
475
+ Mournmail.current_account = account
476
+ mournmail
477
+ end
478
+
220
479
  private
221
480
 
222
481
  def selected_uid
@@ -231,6 +490,16 @@ module Mournmail
231
490
  }
232
491
  end
233
492
 
493
+ def marked_uids
494
+ @buffer.to_s.scan(/^ *\d+(?=\*)/).map(&:to_i)
495
+ end
496
+
497
+ def read_current_mail
498
+ summary = Mournmail.current_summary
499
+ uid = selected_uid
500
+ summary.read_mail(uid)
501
+ end
502
+
234
503
  def scroll_up_or_next_uid
235
504
  begin
236
505
  uid = selected_uid
@@ -254,6 +523,20 @@ module Mournmail
254
523
  end
255
524
  end
256
525
 
526
+ def show_message(mail)
527
+ message_buffer = Buffer.find_or_new("*message*",
528
+ undo_limit: 0, read_only: true)
529
+ message_buffer.apply_mode(Mournmail::MessageMode)
530
+ message_buffer.read_only_edit do
531
+ message_buffer.clear
532
+ message_buffer.insert(mail.render)
533
+ message_buffer.beginning_of_buffer
534
+ end
535
+ message_buffer[:mournmail_mail] = mail
536
+ window = Mournmail.message_window
537
+ window.buffer = message_buffer
538
+ end
539
+
257
540
  def mark_as_seen(uid, update_server)
258
541
  summary_item = Mournmail.current_summary[uid]
259
542
  if summary_item && !summary_item.flags.include?(:Seen)
@@ -269,7 +552,7 @@ module Mournmail
269
552
  Mournmail.background do
270
553
  summary_item.toggle_flag(flag)
271
554
  Mournmail.current_summary.save
272
- next_tick do
555
+ foreground do
273
556
  update_flags(summary_item)
274
557
  end
275
558
  end
@@ -300,5 +583,101 @@ module Mournmail
300
583
  @buffer.forward_line
301
584
  end
302
585
  end
586
+
587
+ def gsub_buffer(re, s)
588
+ @buffer.read_only_edit do
589
+ s = @buffer.to_s.gsub(re, s)
590
+ @buffer.replace(s)
591
+ end
592
+ end
593
+
594
+ def show_search_result(messages,
595
+ query: nil, buffer_name: "*search result*")
596
+ summary_text = messages.map { |m|
597
+ format("%s [ %s ] %s\n",
598
+ m.date.strftime("%m/%d %H:%M"),
599
+ ljust(m.from.to_s, 16),
600
+ ljust(m.subject.to_s, 45))
601
+ }.join
602
+ buffer = Buffer.find_or_new(buffer_name, undo_limit: 0,
603
+ read_only: true)
604
+ buffer.apply_mode(Mournmail::SearchResultMode)
605
+ buffer.read_only_edit do
606
+ buffer.clear
607
+ buffer.insert(summary_text)
608
+ buffer.beginning_of_buffer
609
+ end
610
+ buffer[:messages] = messages
611
+ buffer[:query] = query
612
+ switch_to_buffer(buffer)
613
+ end
614
+
615
+ def ljust(s, n)
616
+ width = 0
617
+ str = +""
618
+ s.gsub(/\t/, " ").each_char do |c|
619
+ w = Buffer.display_width(c)
620
+ width += w
621
+ if width > n
622
+ width -= w
623
+ break
624
+ end
625
+ str.concat(c)
626
+ break if width == n
627
+ end
628
+ str + " " * (n - width)
629
+ end
630
+
631
+ def refile_mails(imap, src_mailbox, uids, dst_mailbox)
632
+ count = 0
633
+ uids.each_slice(100) do |uid_set|
634
+ if dst_mailbox
635
+ imap.uid_copy(uid_set, dst_mailbox)
636
+ end
637
+ imap.uid_store(uid_set, "+FLAGS", [:Deleted])
638
+ count += uid_set.size
639
+ progress = (count.to_f * 100 / uids.size).round
640
+ foreground do
641
+ if dst_mailbox
642
+ message("Refiling mails to #{dst_mailbox}... #{progress}%",
643
+ log: false)
644
+ else
645
+ message("Deleting mails... #{progress}%", log: false)
646
+ end
647
+ end
648
+ end
649
+ foreground do
650
+ if dst_mailbox
651
+ message("Refiled mails to #{dst_mailbox}")
652
+ else
653
+ message("Deleted mails")
654
+ end
655
+ end
656
+ end
657
+
658
+ def current_message
659
+ uid = selected_uid
660
+ item = Mournmail.current_summary[uid]
661
+ message = Groonga["Messages"][item.cache_id]
662
+ if message.nil?
663
+ raise EditorError, "No message found"
664
+ end
665
+ message
666
+ end
667
+
668
+ def delete_from_summary(summary, uids, msg)
669
+ summary.delete_item_if do |item|
670
+ uids.include?(item.uid)
671
+ end
672
+ summary_text = summary.to_s
673
+ summary.save
674
+ foreground do
675
+ @buffer.read_only_edit do
676
+ @buffer.clear
677
+ @buffer.insert(summary_text)
678
+ end
679
+ message(msg)
680
+ end
681
+ end
303
682
  end
304
683
  end