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,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